domain migration
This commit is contained in:
parent
192b810eb4
commit
c04a6ce6fe
26 changed files with 777 additions and 125 deletions
|
|
@ -3,25 +3,25 @@
|
||||||
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 `getall.help` can stay on another host (e.g. GCP).
|
The **main website** at `gentic.email` can stay on another host (e.g. GCP).
|
||||||
Only the email stack lives on the mail VPS:
|
Only the email stack lives on the mail VPS:
|
||||||
|
|
||||||
| Hostname | Points to | Purpose |
|
| Hostname | Points to | Purpose |
|
||||||
|----------|-----------|---------|
|
|----------|-----------|---------|
|
||||||
| `getall.help` | GCP / main site IP | Marketing / main app (unchanged) |
|
| `gentic.email` | GCP / main site IP | Marketing / main app (unchanged) |
|
||||||
| `email.getall.help` | **Mail VPS public IP** | Agmail web UI (Caddy → frontend) |
|
| `email.gentic.email` | **Mail VPS public IP** | Agmail web UI (Caddy → frontend) |
|
||||||
| `mail.getall.help` | **Mail VPS public IP** | Postfix :25 (inbound + outbound) |
|
| `mail.gentic.email` | **Mail VPS public IP** | Postfix :25 (inbound + outbound) |
|
||||||
| `@getall.help` MX | `mail.getall.help` | Delivers mail to the VPS |
|
| `@gentic.email` MX | `mail.gentic.email` | Delivers mail to the VPS |
|
||||||
|
|
||||||
User addresses remain **`user@getall.help`**.
|
User addresses remain **`user@gentic.email`**.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Browser ──HTTPS──► email.getall.help (Caddy :443)
|
Browser ──HTTPS──► email.gentic.email (Caddy :443)
|
||||||
└──► frontend ──► backend ──SMTP──► smtp:25 (Postfix outbound)
|
└──► frontend ──► backend ──SMTP──► smtp:25 (Postfix outbound)
|
||||||
|
|
||||||
Internet ──SMTP:25──► mail.getall.help (Postfix)
|
Internet ──SMTP:25──► mail.gentic.email (Postfix)
|
||||||
├── inbound → backend:2525 → GCS + MongoDB
|
├── inbound → backend:2525 → GCS + MongoDB
|
||||||
└── outbound ← backend (Docker mynetworks)
|
└── outbound ← backend (Docker mynetworks)
|
||||||
```
|
```
|
||||||
|
|
@ -34,7 +34,7 @@ cp .env.example .env
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Open **https://email.getall.help** after DNS and TLS are ready.
|
Open **https://email.gentic.email** after DNS and TLS are ready.
|
||||||
|
|
||||||
| Service | Host port | Purpose |
|
| Service | Host port | Purpose |
|
||||||
|----------|-----------|---------|
|
|----------|-----------|---------|
|
||||||
|
|
@ -60,8 +60,8 @@ MONGO_ROOT_PASSWORD="<strong password>"
|
||||||
GCS_RAW_MIME_BUCKET=<private bucket>
|
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@getall.help
|
CADDY_ACME_EMAIL=admin@gentic.email
|
||||||
CORS_ORIGINS=["https://email.getall.help"]
|
CORS_ORIGINS=["https://email.gentic.email"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Quote values that contain `#`.
|
Quote values that contain `#`.
|
||||||
|
|
@ -74,18 +74,18 @@ Outbound uses the Docker Postfix service by default (`SMTP_HOST=smtp`,
|
||||||
### Split-domain settings (mail VPS)
|
### Split-domain settings (mail VPS)
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# User mailboxes are still @getall.help
|
# User mailboxes are still @gentic.email
|
||||||
PLATFORM_DOMAIN=getall.help
|
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.getall.help
|
APP_PUBLIC_HOST=email.gentic.email
|
||||||
CADDY_ACME_EMAIL=admin@getall.help
|
CADDY_ACME_EMAIL=admin@gentic.email
|
||||||
|
|
||||||
# Postfix hostname (inbound + outbound)
|
# Postfix hostname (inbound + outbound)
|
||||||
MAIL_HOSTNAME=mail.getall.help
|
MAIL_HOSTNAME=mail.gentic.email
|
||||||
MAIL_DOMAIN=getall.help
|
MAIL_DOMAIN=gentic.email
|
||||||
INBOUND_DOMAINS=getall.help
|
INBOUND_DOMAINS=gentic.email
|
||||||
SMTP_PUBLIC_HOST=mail.getall.help
|
SMTP_PUBLIC_HOST=mail.gentic.email
|
||||||
SMTP_HOST=smtp
|
SMTP_HOST=smtp
|
||||||
SMTP_PORT=25
|
SMTP_PORT=25
|
||||||
SMTP_USE_TLS=false
|
SMTP_USE_TLS=false
|
||||||
|
|
@ -100,7 +100,7 @@ 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.getall.help/smtp-credentials**:
|
exact rows at **https://email.gentic.email/smtp-credentials**:
|
||||||
|
|
||||||
### Inbound
|
### Inbound
|
||||||
|
|
||||||
|
|
@ -109,16 +109,16 @@ exact rows at **https://email.getall.help/smtp-credentials**:
|
||||||
| **A** | `@` | main site IP | **Keep** — marketing site |
|
| **A** | `@` | main site IP | **Keep** — marketing site |
|
||||||
| **A** | `email` | `<VPS_IP>` | Web UI |
|
| **A** | `email` | `<VPS_IP>` | Web UI |
|
||||||
| **A** | `mail` | `<VPS_IP>` | Postfix |
|
| **A** | `mail` | `<VPS_IP>` | Postfix |
|
||||||
| **MX** | `@` | `10 mail.getall.help` | Priority 10 |
|
| **MX** | `@` | `10 mail.gentic.email` | Priority 10 |
|
||||||
|
|
||||||
### Outbound authentication
|
### Outbound authentication
|
||||||
|
|
||||||
| Type | Host | Value | Notes |
|
| Type | Host | Value | Notes |
|
||||||
|------|------|-------|-------|
|
|------|------|-------|-------|
|
||||||
| **TXT** | `@` | `v=spf1 mx a ip4:<VPS_IP> ~all` | SPF for `@getall.help` mail |
|
| **TXT** | `@` | `v=spf1 mx a ip4:<VPS_IP> ~all` | SPF for `@gentic.email` mail |
|
||||||
| **TXT** | `_spf` | `v=spf1 ip4:<VPS_IP> ~all` | **Required** — company domains SPF-include this |
|
| **TXT** | `_spf` | `v=spf1 ip4:<VPS_IP> ~all` | **Required** — company domains SPF-include this |
|
||||||
| **TXT** | `agmail._domainkey` | app DKIM public key | From `/smtp-credentials` |
|
| **TXT** | `agmail._domainkey` | app DKIM public key | From `/smtp-credentials` |
|
||||||
| **TXT** | `_dmarc` | `v=DMARC1; p=quarantine; rua=mailto:dmarc@getall.help` | One record only |
|
| **TXT** | `_dmarc` | `v=DMARC1; p=quarantine; rua=mailto:dmarc@gentic.email` | One record only |
|
||||||
|
|
||||||
Remove any old Amazon SES Easy DKIM CNAMEs and `include:amazonses.com` from SPF.
|
Remove any old Amazon SES Easy DKIM CNAMEs and `include:amazonses.com` from SPF.
|
||||||
|
|
||||||
|
|
@ -140,22 +140,22 @@ nc -vz -w 5 gmail-smtp-in.l.google.com 25
|
||||||
|
|
||||||
### Reverse DNS (PTR) — required for Gmail/Outlook
|
### Reverse DNS (PTR) — required for Gmail/Outlook
|
||||||
|
|
||||||
Major receivers expect your VPS IP reverse DNS to match `mail.getall.help`.
|
Major receivers expect your VPS IP reverse DNS to match `mail.gentic.email`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dig +short -x YOUR_VPS_IP
|
dig +short -x YOUR_VPS_IP
|
||||||
# Good: mail.getall.help.
|
# Good: mail.gentic.email.
|
||||||
```
|
```
|
||||||
|
|
||||||
Set PTR in your VPS provider panel (is\*hosting: client area → network /
|
Set PTR in your VPS provider panel (is\*hosting: client area → network /
|
||||||
rDNS, or open a support ticket) to `mail.getall.help`.
|
rDNS, or open a support ticket) to `mail.gentic.email`.
|
||||||
|
|
||||||
### STARTTLS on Postfix (port 25)
|
### STARTTLS on Postfix (port 25)
|
||||||
|
|
||||||
Gmail/Zoho use STARTTLS. Postfix generates a cert on first boot. After rebuild:
|
Gmail/Zoho use STARTTLS. Postfix generates a cert on first boot. After rebuild:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
printf 'EHLO test\r\nSTARTTLS\r\n' | nc mail.getall.help 25
|
printf 'EHLO test\r\nSTARTTLS\r\n' | nc mail.gentic.email 25
|
||||||
# expect: 220 Ready to start TLS (not 454 TLS not available)
|
# expect: 220 Ready to start TLS (not 454 TLS not available)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -163,7 +163,7 @@ printf 'EHLO test\r\nSTARTTLS\r\n' | nc mail.getall.help 25
|
||||||
|
|
||||||
Caddy obtains Let's Encrypt certificates automatically when:
|
Caddy obtains Let's Encrypt certificates automatically when:
|
||||||
|
|
||||||
1. `APP_PUBLIC_HOST=email.getall.help` resolves to this server's public IP
|
1. `APP_PUBLIC_HOST=email.gentic.email` resolves 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,13 +173,13 @@ Config: `deploy/caddy/Caddyfile` → reverse-proxies to `frontend:80`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# HTTPS UI
|
# HTTPS UI
|
||||||
curl -fsSI https://email.getall.help/health
|
curl -fsSI https://email.gentic.email/health
|
||||||
|
|
||||||
# Inbound mail (account must exist)
|
# Inbound mail (account must exist)
|
||||||
swaks \
|
swaks \
|
||||||
--server mail.getall.help:25 \
|
--server mail.gentic.email:25 \
|
||||||
--from sender@example.com \
|
--from sender@example.com \
|
||||||
--to existing-user@getall.help \
|
--to existing-user@gentic.email \
|
||||||
--header "Subject: inbound smoke test" \
|
--header "Subject: inbound smoke test" \
|
||||||
--body "Verify delivery"
|
--body "Verify delivery"
|
||||||
|
|
||||||
|
|
@ -211,7 +211,7 @@ 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.getall.help
|
# APP_PUBLIC_HOST=email.gentic.email
|
||||||
# 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 +227,12 @@ docker compose logs -f caddy
|
||||||
Wait 30–60 seconds, then test:
|
Wait 30–60 seconds, then test:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSI https://email.getall.help/
|
curl -fsSI https://email.gentic.email/
|
||||||
```
|
```
|
||||||
|
|
||||||
Requirements for Let's Encrypt:
|
Requirements for Let's Encrypt:
|
||||||
|
|
||||||
- `email.getall.help` A record → this server's public IP
|
- `email.gentic.email` A record → 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`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,15 @@ class Settings(BaseSettings):
|
||||||
jwt_secret: str = "replace-this-development-secret"
|
jwt_secret: str = "replace-this-development-secret"
|
||||||
access_token_minutes: int = 60
|
access_token_minutes: int = 60
|
||||||
|
|
||||||
platform_domain: str = "getall.help"
|
platform_domain: str = "gentic.email"
|
||||||
platform_spf_include: str = "_spf.getall.help"
|
platform_spf_include: str = "_spf.gentic.email"
|
||||||
app_public_host: str = "email.getall.help"
|
app_public_host: str = "email.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)
|
||||||
smtp_host: str = "smtp"
|
smtp_host: str = "smtp"
|
||||||
smtp_port: int = 25
|
smtp_port: int = 25
|
||||||
smtp_public_host: str = "mail.getall.help"
|
smtp_public_host: str = "mail.gentic.email"
|
||||||
smtp_username: str = ""
|
smtp_username: str = ""
|
||||||
smtp_password: str = ""
|
smtp_password: str = ""
|
||||||
smtp_use_tls: bool = False
|
smtp_use_tls: bool = False
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ from app.config import get_settings
|
||||||
from app.database import get_postgres_session
|
from app.database import get_postgres_session
|
||||||
from app.models import Account
|
from app.models import Account
|
||||||
from app.schemas import AccountRegister, AccountRead, AuthResponse, LoginRequest
|
from app.schemas import AccountRegister, AccountRead, AuthResponse, LoginRequest
|
||||||
|
from app.services.domain_policy import assert_company_domain_allowed
|
||||||
|
from app.services.domain_provisioning import account_is_domain_owner
|
||||||
from app.services.domain_provisioning import (
|
from app.services.domain_provisioning import (
|
||||||
get_domain_by_name,
|
get_domain_by_name,
|
||||||
provision_mail_domain,
|
provision_mail_domain,
|
||||||
|
|
@ -50,7 +52,7 @@ def validate_password(value: str) -> None:
|
||||||
|
|
||||||
|
|
||||||
def build_email(payload: AccountRegister) -> tuple[str, str | None]:
|
def build_email(payload: AccountRegister) -> tuple[str, str | None]:
|
||||||
domain = "getall.help"
|
domain = settings.platform_domain
|
||||||
company_domain = None
|
company_domain = None
|
||||||
|
|
||||||
if payload.account_type == "company":
|
if payload.account_type == "company":
|
||||||
|
|
@ -59,12 +61,16 @@ def build_email(payload: AccountRegister) -> tuple[str, str | None]:
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail="Company domain is required for company accounts.",
|
detail="Company domain is required for company accounts.",
|
||||||
)
|
)
|
||||||
domain = payload.company_domain.strip().lower().removeprefix("@")
|
try:
|
||||||
if not domain_pattern.fullmatch(domain):
|
domain = assert_company_domain_allowed(
|
||||||
|
payload.company_domain,
|
||||||
|
settings.platform_domain,
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail="Enter a valid company domain, such as example.com.",
|
detail=str(error),
|
||||||
)
|
) from error
|
||||||
company_domain = domain
|
company_domain = domain
|
||||||
|
|
||||||
email = f"{payload.email_name.lower()}@{domain}"
|
email = f"{payload.email_name.lower()}@{domain}"
|
||||||
|
|
@ -88,6 +94,21 @@ def create_access_token(account: Account) -> str:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def account_read(
|
||||||
|
session: AsyncSession,
|
||||||
|
account: Account,
|
||||||
|
) -> AccountRead:
|
||||||
|
is_owner = await account_is_domain_owner(
|
||||||
|
session,
|
||||||
|
account.id,
|
||||||
|
account.company_domain,
|
||||||
|
)
|
||||||
|
return AccountRead.model_validate(
|
||||||
|
account,
|
||||||
|
update={"is_domain_owner": is_owner},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_current_account(
|
async def get_current_account(
|
||||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||||
session: AsyncSession = Depends(get_postgres_session),
|
session: AsyncSession = Depends(get_postgres_session),
|
||||||
|
|
@ -180,7 +201,7 @@ async def register(
|
||||||
await session.refresh(account)
|
await session.refresh(account)
|
||||||
return AuthResponse(
|
return AuthResponse(
|
||||||
access_token=create_access_token(account),
|
access_token=create_access_token(account),
|
||||||
account=AccountRead.model_validate(account),
|
account=await account_read(session, account),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -203,10 +224,13 @@ async def login(
|
||||||
)
|
)
|
||||||
return AuthResponse(
|
return AuthResponse(
|
||||||
access_token=create_access_token(account),
|
access_token=create_access_token(account),
|
||||||
account=AccountRead.model_validate(account),
|
account=await account_read(session, account),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=AccountRead)
|
@router.get("/me", response_model=AccountRead)
|
||||||
async def get_me(account: Account = Depends(get_current_account)) -> Account:
|
async def get_me(
|
||||||
return account
|
account: Account = Depends(get_current_account),
|
||||||
|
session: AsyncSession = Depends(get_postgres_session),
|
||||||
|
) -> AccountRead:
|
||||||
|
return await account_read(session, account)
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,11 @@ from app.schemas import (
|
||||||
SmtpCredentialRead,
|
SmtpCredentialRead,
|
||||||
)
|
)
|
||||||
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_provisioning import (
|
from app.services.domain_provisioning import (
|
||||||
activate_company_domain,
|
activate_company_domain,
|
||||||
create_smtp_credential,
|
create_smtp_credential,
|
||||||
|
ensure_ownership_dns_record,
|
||||||
ensure_platform_domain,
|
ensure_platform_domain,
|
||||||
get_account_company_domain,
|
get_account_company_domain,
|
||||||
provision_mail_domain,
|
provision_mail_domain,
|
||||||
|
|
@ -102,12 +104,14 @@ async def verify_my_company_dns(
|
||||||
checks=[],
|
checks=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
verification_token = await ensure_ownership_dns_record(session, domain)
|
||||||
checks = verify_company_dns(
|
checks = verify_company_dns(
|
||||||
domain_name=domain.domain_name,
|
domain_name=domain.domain_name,
|
||||||
shared_mail_host=settings.smtp_public_host,
|
shared_mail_host=settings.smtp_public_host,
|
||||||
dkim_selector=domain.dkim_selector,
|
dkim_selector=domain.dkim_selector,
|
||||||
dkim_public_key=domain.dkim_public_key,
|
dkim_public_key=domain.dkim_public_key,
|
||||||
platform_spf_include=settings.platform_spf_include,
|
platform_spf_include=settings.platform_spf_include,
|
||||||
|
verification_token=verification_token,
|
||||||
)
|
)
|
||||||
verified = all(check["verified"] for check in checks)
|
verified = all(check["verified"] for check in checks)
|
||||||
if verified:
|
if verified:
|
||||||
|
|
@ -151,19 +155,16 @@ async def register_company_domain(
|
||||||
) -> MailDomain:
|
) -> MailDomain:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
try:
|
try:
|
||||||
normalized = validate_domain(payload.domain_name)
|
normalized = assert_company_domain_allowed(
|
||||||
|
payload.domain_name,
|
||||||
|
settings.platform_domain,
|
||||||
|
)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail=str(error),
|
detail=str(error),
|
||||||
) from error
|
) from error
|
||||||
|
|
||||||
if normalized == settings.platform_domain:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
||||||
detail="Use the platform domain endpoint for getall.help.",
|
|
||||||
)
|
|
||||||
|
|
||||||
existing = await session.scalar(
|
existing = await session.scalar(
|
||||||
select(MailDomain).where(MailDomain.domain_name == normalized)
|
select(MailDomain).where(MailDomain.domain_name == normalized)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ class AccountRead(BaseModel):
|
||||||
date_of_birth: date
|
date_of_birth: date
|
||||||
phone_number: str
|
phone_number: str
|
||||||
company_domain: str | None
|
company_domain: str | None
|
||||||
|
is_domain_owner: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ from __future__ import annotations
|
||||||
import dns.exception
|
import dns.exception
|
||||||
import dns.resolver
|
import dns.resolver
|
||||||
|
|
||||||
|
OWNERSHIP_PREFIX = "agmail-verify="
|
||||||
|
|
||||||
|
|
||||||
def _normalize_txt(value: str) -> str:
|
def _normalize_txt(value: str) -> str:
|
||||||
return " ".join(value.split()).strip().lower()
|
return " ".join(value.split()).strip().lower()
|
||||||
|
|
@ -51,6 +53,29 @@ def _lookup_mx(domain_name: str) -> list[tuple[int, str]]:
|
||||||
return hosts
|
return hosts
|
||||||
|
|
||||||
|
|
||||||
|
def ownership_record_value(token: str) -> str:
|
||||||
|
return f"{OWNERSHIP_PREFIX}{token}"
|
||||||
|
|
||||||
|
|
||||||
|
def ownership_token_from_value(value: str) -> str:
|
||||||
|
normalized = value.strip()
|
||||||
|
if normalized.lower().startswith(OWNERSHIP_PREFIX):
|
||||||
|
return normalized[len(OWNERSHIP_PREFIX) :]
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def verify_ownership(domain_name: str, verification_token: str) -> tuple[bool, str]:
|
||||||
|
name = f"_agmail-verify.{domain_name}"
|
||||||
|
expected = _normalize_txt(ownership_record_value(verification_token))
|
||||||
|
records = [_normalize_txt(record) for record in _lookup_txt(name)]
|
||||||
|
if expected in records:
|
||||||
|
return True, "Domain ownership verified via TXT record."
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"Add TXT at _agmail-verify.{domain_name} with value {ownership_record_value(verification_token)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def verify_mx(domain_name: str, expected_host: str) -> tuple[bool, str]:
|
def verify_mx(domain_name: str, expected_host: str) -> tuple[bool, str]:
|
||||||
expected = expected_host.rstrip(".").lower()
|
expected = expected_host.rstrip(".").lower()
|
||||||
hosts = _lookup_mx(domain_name)
|
hosts = _lookup_mx(domain_name)
|
||||||
|
|
@ -96,7 +121,7 @@ def verify_platform_spf(platform_spf_host: str, mail_host: str) -> tuple[bool, s
|
||||||
if not spf_records:
|
if not spf_records:
|
||||||
return (
|
return (
|
||||||
False,
|
False,
|
||||||
f"No SPF TXT at {platform_spf_host}. On getall.help add Host _spf with "
|
f"No SPF TXT at {platform_spf_host}. On the platform domain add Host _spf with "
|
||||||
f"v=spf1 ip4:<mail-server-ip> ~all.",
|
f"v=spf1 ip4:<mail-server-ip> ~all.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -138,10 +163,13 @@ def verify_dmarc(domain_name: str, expected_value: str) -> tuple[bool, str]:
|
||||||
return False, f"No DMARC TXT record found at {name}."
|
return False, f"No DMARC TXT record found at {name}."
|
||||||
expected = _normalize_txt(expected_value)
|
expected = _normalize_txt(expected_value)
|
||||||
for record in records:
|
for record in records:
|
||||||
if record == expected or record.startswith("v=dmarc1"):
|
if record == expected:
|
||||||
if "v=dmarc1" in record:
|
return True, "DMARC record matches the expected value."
|
||||||
return True, "DMARC record is present."
|
return (
|
||||||
return False, "DMARC TXT record is missing or incomplete."
|
False,
|
||||||
|
"DMARC TXT must match the value shown in setup exactly "
|
||||||
|
"(do not rely on an existing third-party DMARC record).",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def verify_company_dns(
|
def verify_company_dns(
|
||||||
|
|
@ -151,15 +179,14 @@ def verify_company_dns(
|
||||||
dkim_selector: str,
|
dkim_selector: str,
|
||||||
dkim_public_key: str,
|
dkim_public_key: str,
|
||||||
platform_spf_include: str,
|
platform_spf_include: str,
|
||||||
|
verification_token: str,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
|
"""Customer-domain checks only (ownership + mail authentication)."""
|
||||||
dmarc_expected = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}"
|
dmarc_expected = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}"
|
||||||
checks = [
|
checks = [
|
||||||
|
("ownership", *verify_ownership(domain_name, verification_token)),
|
||||||
("mx", *verify_mx(domain_name, shared_mail_host)),
|
("mx", *verify_mx(domain_name, shared_mail_host)),
|
||||||
("spf", *verify_spf(domain_name, platform_spf_include)),
|
("spf", *verify_spf(domain_name, platform_spf_include)),
|
||||||
(
|
|
||||||
"platform_spf",
|
|
||||||
*verify_platform_spf(platform_spf_include, shared_mail_host),
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
"dkim",
|
"dkim",
|
||||||
*verify_dkim(domain_name, dkim_selector, dkim_public_key),
|
*verify_dkim(domain_name, dkim_selector, dkim_public_key),
|
||||||
|
|
|
||||||
49
backend/app/services/domain_policy.py
Normal file
49
backend/app/services/domain_policy.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""Company domain registration policy."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.services.domain_provisioning import normalize_domain, validate_domain
|
||||||
|
|
||||||
|
# Well-known domains that must never be used for company onboarding.
|
||||||
|
BLOCKED_COMPANY_DOMAINS = frozenset(
|
||||||
|
{
|
||||||
|
"google.com",
|
||||||
|
"gmail.com",
|
||||||
|
"yahoo.com",
|
||||||
|
"outlook.com",
|
||||||
|
"hotmail.com",
|
||||||
|
"live.com",
|
||||||
|
"icloud.com",
|
||||||
|
"apple.com",
|
||||||
|
"microsoft.com",
|
||||||
|
"amazon.com",
|
||||||
|
"facebook.com",
|
||||||
|
"meta.com",
|
||||||
|
"linkedin.com",
|
||||||
|
"proton.me",
|
||||||
|
"protonmail.com",
|
||||||
|
"aol.com",
|
||||||
|
"zoho.com",
|
||||||
|
"fastmail.com",
|
||||||
|
"yandex.com",
|
||||||
|
"mail.ru",
|
||||||
|
"cloudflare.com",
|
||||||
|
"github.com",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_company_domain_allowed(domain: str, platform_domain: str) -> str:
|
||||||
|
"""Validate format and reject reserved or platform domains."""
|
||||||
|
normalized = validate_domain(domain)
|
||||||
|
if normalized == normalize_domain(platform_domain):
|
||||||
|
raise ValueError(
|
||||||
|
f"You cannot register {platform_domain} as a company domain. "
|
||||||
|
"Use a domain your organization controls."
|
||||||
|
)
|
||||||
|
if normalized in BLOCKED_COMPANY_DOMAINS:
|
||||||
|
raise ValueError(
|
||||||
|
"That domain cannot be used for company signup. "
|
||||||
|
"Enter a domain you control and can edit in DNS."
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
@ -94,11 +94,21 @@ def build_company_dns_records(
|
||||||
dkim_selector: str,
|
dkim_selector: str,
|
||||||
dkim_public_key: str,
|
dkim_public_key: str,
|
||||||
platform_spf_include: str,
|
platform_spf_include: str,
|
||||||
|
verification_token: str,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Zoho-style records: shared MX host, SPF include, per-domain DKIM/DMARC."""
|
"""Zoho-style records: ownership proof, shared MX host, SPF include, DKIM/DMARC."""
|
||||||
|
from app.services.dns_verification import ownership_record_value
|
||||||
|
|
||||||
dkim_value = f"v=DKIM1; k=rsa; p={dkim_public_key}"
|
dkim_value = f"v=DKIM1; k=rsa; p={dkim_public_key}"
|
||||||
dmarc_value = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}"
|
dmarc_value = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}"
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
"record_type": "TXT",
|
||||||
|
"host": namecheap_host("_agmail-verify", domain_name),
|
||||||
|
"value": ownership_record_value(verification_token),
|
||||||
|
"purpose": "ownership",
|
||||||
|
"ttl": 3600,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"record_type": "MX",
|
"record_type": "MX",
|
||||||
"host": namecheap_host("@", domain_name),
|
"host": namecheap_host("@", domain_name),
|
||||||
|
|
@ -273,12 +283,14 @@ async def provision_mail_domain(
|
||||||
private_key, public_key = generate_dkim_keys()
|
private_key, public_key = generate_dkim_keys()
|
||||||
if domain_type == "company":
|
if domain_type == "company":
|
||||||
mail_host = settings.smtp_public_host
|
mail_host = settings.smtp_public_host
|
||||||
|
verification_token = secrets.token_urlsafe(24)
|
||||||
dns_records = build_company_dns_records(
|
dns_records = build_company_dns_records(
|
||||||
normalized,
|
normalized,
|
||||||
settings.smtp_public_host,
|
settings.smtp_public_host,
|
||||||
dkim_selector,
|
dkim_selector,
|
||||||
public_key,
|
public_key,
|
||||||
settings.platform_spf_include,
|
settings.platform_spf_include,
|
||||||
|
verification_token,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
dns_records = build_dns_records(
|
dns_records = build_dns_records(
|
||||||
|
|
@ -331,6 +343,48 @@ async def activate_company_domain(session: AsyncSession, domain: MailDomain) ->
|
||||||
return domain
|
return domain
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_ownership_dns_record(session: AsyncSession, domain: MailDomain) -> str:
|
||||||
|
"""Return the ownership verification token, creating a DNS record if missing."""
|
||||||
|
from app.services.dns_verification import ownership_record_value, ownership_token_from_value
|
||||||
|
|
||||||
|
existing = next(
|
||||||
|
(record for record in domain.dns_records if record.purpose == "ownership"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return ownership_token_from_value(existing.value)
|
||||||
|
|
||||||
|
token = secrets.token_urlsafe(24)
|
||||||
|
session.add(
|
||||||
|
DomainDnsRecord(
|
||||||
|
domain_id=domain.id,
|
||||||
|
record_type="TXT",
|
||||||
|
host=namecheap_host("_agmail-verify", domain.domain_name),
|
||||||
|
value=ownership_record_value(token),
|
||||||
|
purpose="ownership",
|
||||||
|
ttl=3600,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.flush()
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def account_is_domain_owner(
|
||||||
|
session: AsyncSession,
|
||||||
|
account_id,
|
||||||
|
company_domain_name: str | None,
|
||||||
|
) -> bool:
|
||||||
|
if not company_domain_name:
|
||||||
|
return False
|
||||||
|
domain_id = await session.scalar(
|
||||||
|
select(MailDomain.id).where(
|
||||||
|
MailDomain.domain_name == company_domain_name,
|
||||||
|
MailDomain.account_id == account_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return domain_id is not None
|
||||||
|
|
||||||
|
|
||||||
async def get_account_company_domain(
|
async def get_account_company_domain(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
account_id,
|
account_id,
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,16 @@
|
||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from app.services.dns_verification import verify_company_dns
|
import pytest
|
||||||
|
|
||||||
|
from app.services.dns_verification import (
|
||||||
|
ownership_record_value,
|
||||||
|
verify_company_dns,
|
||||||
|
verify_dmarc,
|
||||||
|
)
|
||||||
|
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,
|
||||||
build_company_dns_records,
|
build_company_dns_records,
|
||||||
build_shared_mx_spf_value,
|
build_shared_mx_spf_value,
|
||||||
register_relay_domain,
|
register_relay_domain,
|
||||||
|
|
@ -13,30 +21,34 @@ from app.services.domain_provisioning import (
|
||||||
def test_build_company_dns_records_use_shared_mx_and_spf_include():
|
def test_build_company_dns_records_use_shared_mx_and_spf_include():
|
||||||
records = build_company_dns_records(
|
records = build_company_dns_records(
|
||||||
"acme.com",
|
"acme.com",
|
||||||
"mail.getall.help",
|
"mail.gentic.email",
|
||||||
"agmail",
|
"agmail",
|
||||||
"abc123public",
|
"abc123public",
|
||||||
"_spf.getall.help",
|
"_spf.gentic.email",
|
||||||
|
"verify-token-123",
|
||||||
)
|
)
|
||||||
purposes = [record["purpose"] for record in records]
|
purposes = [record["purpose"] for record in records]
|
||||||
assert purposes == ["mx", "spf", "dkim", "dmarc"]
|
assert purposes == ["ownership", "mx", "spf", "dkim", "dmarc"]
|
||||||
|
|
||||||
|
ownership = next(record for record in records if record["purpose"] == "ownership")
|
||||||
mx = next(record for record in records if record["purpose"] == "mx")
|
mx = next(record for record in records if record["purpose"] == "mx")
|
||||||
spf = next(record for record in records if record["purpose"] == "spf")
|
spf = next(record for record in records if record["purpose"] == "spf")
|
||||||
dkim = next(record for record in records if record["purpose"] == "dkim")
|
dkim = next(record for record in records if record["purpose"] == "dkim")
|
||||||
|
|
||||||
|
assert ownership["host"] == "_agmail-verify"
|
||||||
|
assert ownership["value"] == ownership_record_value("verify-token-123")
|
||||||
assert mx["host"] == "@"
|
assert mx["host"] == "@"
|
||||||
assert mx["value"] == "mail.getall.help"
|
assert mx["value"] == "mail.gentic.email"
|
||||||
assert mx["priority"] == 10
|
assert mx["priority"] == 10
|
||||||
assert spf["value"] == "v=spf1 include:_spf.getall.help ~all"
|
assert spf["value"] == "v=spf1 include:_spf.gentic.email ~all"
|
||||||
assert dkim["host"] == "agmail._domainkey"
|
assert dkim["host"] == "agmail._domainkey"
|
||||||
assert "abc123public" in dkim["value"]
|
assert "abc123public" in dkim["value"]
|
||||||
assert not any(record.get("purpose") == "mail_host" for record in records)
|
assert not any(record.get("purpose") == "mail_host" for record in records)
|
||||||
|
|
||||||
|
|
||||||
def test_build_shared_mx_spf_value():
|
def test_build_shared_mx_spf_value():
|
||||||
assert build_shared_mx_spf_value("_spf.getall.help") == (
|
assert build_shared_mx_spf_value("_spf.gentic.email") == (
|
||||||
"v=spf1 include:_spf.getall.help ~all"
|
"v=spf1 include:_spf.gentic.email ~all"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -56,32 +68,48 @@ def test_register_relay_domain_appends_once(tmp_path, monkeypatch):
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
@patch("app.services.dns_verification._lookup_a")
|
def test_assert_company_domain_allowed_blocks_platform_and_google():
|
||||||
@patch("app.services.dns_verification._lookup_mx")
|
with pytest.raises(ValueError, match="platform domain"):
|
||||||
|
assert_company_domain_allowed("gentic.email", "gentic.email")
|
||||||
|
with pytest.raises(ValueError, match="cannot be used"):
|
||||||
|
assert_company_domain_allowed("google.com", "gentic.email")
|
||||||
|
assert assert_company_domain_allowed("acme.com", "gentic.email") == "acme.com"
|
||||||
|
|
||||||
|
|
||||||
@patch("app.services.dns_verification._lookup_txt")
|
@patch("app.services.dns_verification._lookup_txt")
|
||||||
def test_verify_company_dns_all_pass(mock_txt, mock_mx, mock_a):
|
def test_verify_dmarc_requires_exact_match(mock_txt):
|
||||||
mock_mx.return_value = [(10, "mail.getall.help")]
|
expected = "v=DMARC1; p=quarantine; rua=mailto:dmarc@acme.com"
|
||||||
mock_a.return_value = ["38.180.106.242"]
|
mock_txt.return_value = ["v=DMARC1; p=reject; rua=mailto:security@google.com"]
|
||||||
|
verified, message = verify_dmarc("acme.com", expected)
|
||||||
|
assert verified is False
|
||||||
|
assert "exactly" in message.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.dns_verification._lookup_txt")
|
||||||
|
@patch("app.services.dns_verification._lookup_mx")
|
||||||
|
def test_verify_company_dns_all_pass(mock_mx, mock_txt):
|
||||||
|
mock_mx.return_value = [(10, "mail.gentic.email")]
|
||||||
mock_txt.side_effect = lambda name: {
|
mock_txt.side_effect = lambda name: {
|
||||||
"acme.com": ['v=spf1 include:_spf.getall.help ~all'],
|
"_agmail-verify.acme.com": [ownership_record_value("token-abc")],
|
||||||
"_spf.getall.help": ["v=spf1 ip4:38.180.106.242 ~all"],
|
"acme.com": ['v=spf1 include:_spf.gentic.email ~all'],
|
||||||
"agmail._domainkey.acme.com": ["v=DKIM1; k=rsa; p=abc123"],
|
"agmail._domainkey.acme.com": ["v=DKIM1; k=rsa; p=abc123"],
|
||||||
"_dmarc.acme.com": ["v=DMARC1; p=quarantine; rua=mailto:dmarc@acme.com"],
|
"_dmarc.acme.com": ["v=DMARC1; p=quarantine; rua=mailto:dmarc@acme.com"],
|
||||||
}.get(name, [])
|
}.get(name, [])
|
||||||
|
|
||||||
checks = verify_company_dns(
|
checks = verify_company_dns(
|
||||||
domain_name="acme.com",
|
domain_name="acme.com",
|
||||||
shared_mail_host="mail.getall.help",
|
shared_mail_host="mail.gentic.email",
|
||||||
dkim_selector="agmail",
|
dkim_selector="agmail",
|
||||||
dkim_public_key="abc123",
|
dkim_public_key="abc123",
|
||||||
platform_spf_include="_spf.getall.help",
|
platform_spf_include="_spf.gentic.email",
|
||||||
|
verification_token="token-abc",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert all(check["verified"] for check in checks)
|
assert all(check["verified"] for check in checks)
|
||||||
assert [check["purpose"] for check in checks] == [
|
assert [check["purpose"] for check in checks] == [
|
||||||
|
"ownership",
|
||||||
"mx",
|
"mx",
|
||||||
"spf",
|
"spf",
|
||||||
"platform_spf",
|
|
||||||
"dkim",
|
"dkim",
|
||||||
"dmarc",
|
"dmarc",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -9,18 +9,18 @@ from app.services.domain_provisioning import (
|
||||||
|
|
||||||
|
|
||||||
def test_namecheap_host_strips_zone_suffix():
|
def test_namecheap_host_strips_zone_suffix():
|
||||||
assert namecheap_host("mail.getall.help", "getall.help") == "mail"
|
assert namecheap_host("mail.gentic.email", "gentic.email") == "mail"
|
||||||
assert namecheap_host("@", "getall.help") == "@"
|
assert namecheap_host("@", "gentic.email") == "@"
|
||||||
assert (
|
assert (
|
||||||
namecheap_host("agmail._domainkey.getall.help", "getall.help")
|
namecheap_host("agmail._domainkey.gentic.email", "gentic.email")
|
||||||
== "agmail._domainkey"
|
== "agmail._domainkey"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_build_dns_records_uses_namecheap_hosts_and_ip():
|
def test_build_dns_records_uses_namecheap_hosts_and_ip():
|
||||||
records = build_dns_records(
|
records = build_dns_records(
|
||||||
"getall.help",
|
"gentic.email",
|
||||||
"mail.getall.help",
|
"mail.gentic.email",
|
||||||
"agmail",
|
"agmail",
|
||||||
"abc123",
|
"abc123",
|
||||||
mail_server_ip="203.0.113.10",
|
mail_server_ip="203.0.113.10",
|
||||||
|
|
@ -30,7 +30,7 @@ def test_build_dns_records_uses_namecheap_hosts_and_ip():
|
||||||
spf = next(record for record in records if record["purpose"] == "spf")
|
spf = next(record for record in records if record["purpose"] == "spf")
|
||||||
|
|
||||||
assert mx["host"] == "@"
|
assert mx["host"] == "@"
|
||||||
assert mx["value"] == "mail.getall.help"
|
assert mx["value"] == "mail.gentic.email"
|
||||||
assert mx["priority"] == 10
|
assert mx["priority"] == 10
|
||||||
assert a_record["host"] == "mail"
|
assert a_record["host"] == "mail"
|
||||||
assert a_record["value"] == "203.0.113.10"
|
assert a_record["value"] == "203.0.113.10"
|
||||||
|
|
@ -44,11 +44,11 @@ def test_build_spf_value_supports_ipv6():
|
||||||
|
|
||||||
def test_deployment_sections_split_inbound_and_outbound():
|
def test_deployment_sections_split_inbound_and_outbound():
|
||||||
sections = build_deployment_dns_records(
|
sections = build_deployment_dns_records(
|
||||||
domain_name="getall.help",
|
domain_name="gentic.email",
|
||||||
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.getall.help",
|
app_public_host="email.gentic.email",
|
||||||
)
|
)
|
||||||
|
|
||||||
inbound_purposes = [record["purpose"] for record in sections["inbound"]]
|
inbound_purposes = [record["purpose"] for record in sections["inbound"]]
|
||||||
|
|
@ -67,7 +67,7 @@ def test_deployment_sections_split_inbound_and_outbound():
|
||||||
dmarc = next(
|
dmarc = next(
|
||||||
record for record in sections["outbound"] if record["purpose"] == "dmarc"
|
record for record in sections["outbound"] if record["purpose"] == "dmarc"
|
||||||
)
|
)
|
||||||
assert dmarc["value"] == "v=DMARC1; p=quarantine; rua=mailto:dmarc@getall.help"
|
assert dmarc["value"] == "v=DMARC1; p=quarantine; rua=mailto:dmarc@gentic.email"
|
||||||
|
|
||||||
dkim = next(
|
dkim = next(
|
||||||
record for record in sections["outbound"] if record["purpose"] == "dkim"
|
record for record in sections["outbound"] if record["purpose"] == "dkim"
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ def test_summary_uses_account_mailbox_read_state():
|
||||||
"_id": message_id,
|
"_id": message_id,
|
||||||
"direction": "inbound",
|
"direction": "inbound",
|
||||||
"sender": {"name": "Sender", "address": "sender@example.com"},
|
"sender": {"name": "Sender", "address": "sender@example.com"},
|
||||||
"to": [{"name": "user", "address": "user@getall.help"}],
|
"to": [{"name": "user", "address": "user@gentic.email"}],
|
||||||
"subject": "Hello",
|
"subject": "Hello",
|
||||||
"snippet": "A short preview",
|
"snippet": "A short preview",
|
||||||
"status": "received",
|
"status": "received",
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from app.services.raw_mime_storage import StoredMime
|
||||||
|
|
||||||
RAW = (
|
RAW = (
|
||||||
b"From: Sender <sender@example.com>\r\n"
|
b"From: Sender <sender@example.com>\r\n"
|
||||||
b"To: user@getall.help\r\n"
|
b"To: user@gentic.email\r\n"
|
||||||
b"Subject: Hello\r\n\r\n"
|
b"Subject: Hello\r\n\r\n"
|
||||||
b"Retry safe body\r\n"
|
b"Retry safe body\r\n"
|
||||||
)
|
)
|
||||||
|
|
@ -57,9 +57,9 @@ class FakeSession:
|
||||||
def envelope(raw=RAW):
|
def envelope(raw=RAW):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
original_content=raw,
|
original_content=raw,
|
||||||
rcpt_tos=["user@getall.help"],
|
rcpt_tos=["user@gentic.email"],
|
||||||
mail_from="sender@example.com",
|
mail_from="sender@example.com",
|
||||||
account_ids={"user@getall.help": "account-1"},
|
account_ids={"user@gentic.email": "account-1"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -72,7 +72,7 @@ async def test_rcpt_rejects_unknown_account(monkeypatch):
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
target,
|
target,
|
||||||
"missing@getall.help",
|
"missing@gentic.email",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -95,8 +95,8 @@ async def test_rcpt_accepts_account_and_tracks_owner(monkeypatch):
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.startswith("250 2.1.5")
|
assert response.startswith("250 2.1.5")
|
||||||
assert target.rcpt_tos == ["user@getall.help"]
|
assert target.rcpt_tos == ["user@gentic.email"]
|
||||||
assert target.account_ids == {"user@getall.help": "account-1"}
|
assert target.account_ids == {"user@gentic.email": "account-1"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from app.services.mime_parser import parse_mime
|
||||||
def test_parse_mime_extracts_fallback_sender_bodies_and_attachments():
|
def test_parse_mime_extracts_fallback_sender_bodies_and_attachments():
|
||||||
raw = (
|
raw = (
|
||||||
b"From: sender@example.com\r\n"
|
b"From: sender@example.com\r\n"
|
||||||
b"To: User <user@getall.help>\r\n"
|
b"To: User <user@gentic.email>\r\n"
|
||||||
b"Subject: Production test\r\n"
|
b"Subject: Production test\r\n"
|
||||||
b"Message-ID: <one@example.com>\r\n"
|
b"Message-ID: <one@example.com>\r\n"
|
||||||
b"MIME-Version: 1.0\r\n"
|
b"MIME-Version: 1.0\r\n"
|
||||||
|
|
|
||||||
|
|
@ -61,13 +61,13 @@ services:
|
||||||
MONGO_URL: mongodb://${MONGO_ROOT_USER:-agmail}:${MONGO_ROOT_PASSWORD:-agmail_dev_password}@mongodb:27017/?authSource=admin
|
MONGO_URL: mongodb://${MONGO_ROOT_USER:-agmail}:${MONGO_ROOT_PASSWORD:-agmail_dev_password}@mongodb:27017/?authSource=admin
|
||||||
MONGO_DATABASE: ${MONGO_DATABASE:-agmail_messages}
|
MONGO_DATABASE: ${MONGO_DATABASE:-agmail_messages}
|
||||||
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:-getall.help}
|
PLATFORM_DOMAIN: ${PLATFORM_DOMAIN:-gentic.email}
|
||||||
PLATFORM_SPF_INCLUDE: ${PLATFORM_SPF_INCLUDE:-_spf.getall.help}
|
PLATFORM_SPF_INCLUDE: ${PLATFORM_SPF_INCLUDE:-_spf.gentic.email}
|
||||||
APP_PUBLIC_HOST: ${APP_PUBLIC_HOST:-email.getall.help}
|
APP_PUBLIC_HOST: ${APP_PUBLIC_HOST:-email.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}
|
||||||
SMTP_PUBLIC_HOST: ${SMTP_PUBLIC_HOST:-mail.getall.help}
|
SMTP_PUBLIC_HOST: ${SMTP_PUBLIC_HOST:-mail.gentic.email}
|
||||||
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
SMTP_USE_TLS: ${SMTP_USE_TLS:-false}
|
SMTP_USE_TLS: ${SMTP_USE_TLS:-false}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ 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.getall.help}
|
APP_PUBLIC_HOST: ${APP_PUBLIC_HOST:-email.gentic.email}
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,11 @@ services:
|
||||||
context: ../../smtp
|
context: ../../smtp
|
||||||
container_name: agmail-smtp
|
container_name: agmail-smtp
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
hostname: ${MAIL_HOSTNAME:-mail.getall.help}
|
hostname: ${MAIL_HOSTNAME:-mail.gentic.email}
|
||||||
environment:
|
environment:
|
||||||
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.getall.help}
|
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.gentic.email}
|
||||||
MAIL_DOMAIN: ${MAIL_DOMAIN:-getall.help}
|
MAIL_DOMAIN: ${MAIL_DOMAIN:-gentic.email}
|
||||||
INBOUND_DOMAINS: ${INBOUND_DOMAINS:-getall.help}
|
INBOUND_DOMAINS: ${INBOUND_DOMAINS:-gentic.email}
|
||||||
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}
|
||||||
|
|
|
||||||
101
docs/GENTIC_EMAIL_DNS.md
Normal file
101
docs/GENTIC_EMAIL_DNS.md
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Migrating mail platform DNS to gentic.email
|
||||||
|
|
||||||
|
This guide covers DNS changes when moving from `getall.help` to `gentic.email`.
|
||||||
|
|
||||||
|
## Architecture (unchanged concept)
|
||||||
|
|
||||||
|
| Hostname | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `gentic.email` | Platform domain — individual users get `@gentic.email` addresses |
|
||||||
|
| `email.gentic.email` | Web UI (Caddy HTTPS) |
|
||||||
|
| `mail.gentic.email` | Postfix SMTP (inbound :25 + outbound) |
|
||||||
|
| `_spf.gentic.email` | Platform SPF include target (TXT on `gentic.email`, host `_spf`) |
|
||||||
|
|
||||||
|
Company/custom domains still point **their** MX/SPF/DKIM/DMARC at your shared mail host.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Update server environment
|
||||||
|
|
||||||
|
Copy and edit `.env` from `.env.example`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PLATFORM_DOMAIN=gentic.email
|
||||||
|
PLATFORM_SPF_INCLUDE=_spf.gentic.email
|
||||||
|
APP_PUBLIC_HOST=email.gentic.email
|
||||||
|
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"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Redeploy app + SMTP + Caddy stacks after updating env.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. DNS at your registrar (gentic.email zone)
|
||||||
|
|
||||||
|
Add or update these records at the **gentic.email** DNS provider:
|
||||||
|
|
||||||
|
### Inbound mail + web
|
||||||
|
|
||||||
|
| Type | Host | Value | Notes |
|
||||||
|
|------|------|-------|-------|
|
||||||
|
| **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)
|
||||||
|
|
||||||
|
| Type | Host | Value |
|
||||||
|
|------|------|-------|
|
||||||
|
| **TXT** | `_spf` | `v=spf1 ip4:<VPS_PUBLIC_IP> ~all` |
|
||||||
|
| **TXT** | `@` | `v=spf1 mx a ip4:<VPS_PUBLIC_IP> ~all` |
|
||||||
|
| **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.
|
||||||
|
|
||||||
|
### Reverse DNS (PTR)
|
||||||
|
|
||||||
|
Ask your VPS provider to set PTR for `<VPS_PUBLIC_IP>` → `mail.gentic.email`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Company domain onboarding (new checks)
|
||||||
|
|
||||||
|
Each company domain must now publish **five** customer records:
|
||||||
|
|
||||||
|
1. **Ownership** — `TXT` `_agmail-verify` = `agmail-verify=<token>` (unique per domain, shown in setup UI)
|
||||||
|
2. **MX** → `mail.gentic.email`
|
||||||
|
3. **SPF** → `v=spf1 include:_spf.gentic.email ~all`
|
||||||
|
4. **DKIM** → `agmail._domainkey` with your generated public key
|
||||||
|
5. **DMARC** — must match the exact value in setup (not any pre-existing DMARC)
|
||||||
|
|
||||||
|
Blocked domains (`google.com`, etc.) cannot be registered as company domains.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Cutover checklist
|
||||||
|
|
||||||
|
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`
|
||||||
|
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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Old getall.help records
|
||||||
|
|
||||||
|
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
|
||||||
|
- `_spf.getall.help` if no longer used
|
||||||
|
|
||||||
|
Keep old records until you're confident nothing still references `getall.help`.
|
||||||
|
|
@ -3,6 +3,14 @@ import { useState } from "react";
|
||||||
import { verifyCompanyDns } from "../api";
|
import { verifyCompanyDns } from "../api";
|
||||||
import Brand from "./Brand";
|
import Brand from "./Brand";
|
||||||
|
|
||||||
|
const CHECK_LABELS = {
|
||||||
|
ownership: "Domain ownership",
|
||||||
|
mx: "MX",
|
||||||
|
spf: "SPF",
|
||||||
|
dkim: "DKIM",
|
||||||
|
dmarc: "DMARC",
|
||||||
|
};
|
||||||
|
|
||||||
function DnsRecordTable({ records }) {
|
function DnsRecordTable({ records }) {
|
||||||
async function copyValue(value) {
|
async function copyValue(value) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -99,8 +107,16 @@ export default function CompanyDnsSetup({ domain, account, onComplete, onLogout
|
||||||
<DnsRecordTable records={domain.dns_records} />
|
<DnsRecordTable records={domain.dns_records} />
|
||||||
|
|
||||||
<ul className="dns-notes">
|
<ul className="dns-notes">
|
||||||
|
<li>
|
||||||
|
Add the <strong>ownership</strong> TXT first — this proves you control the domain.
|
||||||
|
Without it, verification cannot succeed even if MX/SPF look correct.
|
||||||
|
</li>
|
||||||
<li>Point MX to <strong>{domain.mail_host}</strong> — you do not need a separate mail A record.</li>
|
<li>Point MX to <strong>{domain.mail_host}</strong> — you do not need a separate mail A record.</li>
|
||||||
<li>SPF uses <code>include:_spf.getall.help</code> — that record must exist on <strong>getall.help</strong> (Host <code>_spf</code>, TXT <code>v=spf1 ip4:<mail-server-ip> ~all</code>).</li>
|
<li>
|
||||||
|
SPF uses <code>include:_spf.gentic.email</code>. The platform operator maintains
|
||||||
|
<code>_spf</code> on <strong>gentic.email</strong>; you only add SPF on your domain.
|
||||||
|
</li>
|
||||||
|
<li>DMARC must match the exact value in the table — an existing third-party DMARC record is not accepted.</li>
|
||||||
<li>DNS changes can take a few minutes to propagate.</li>
|
<li>DNS changes can take a few minutes to propagate.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|
@ -113,7 +129,7 @@ export default function CompanyDnsSetup({ domain, account, onComplete, onLogout
|
||||||
>
|
>
|
||||||
{check.verified ? <CheckCircle2 size={16} /> : <XCircle size={16} />}
|
{check.verified ? <CheckCircle2 size={16} /> : <XCircle size={16} />}
|
||||||
<div>
|
<div>
|
||||||
<strong>{check.purpose.toUpperCase()}</strong>
|
<strong>{CHECK_LABELS[check.purpose] || check.purpose.toUpperCase()}</strong>
|
||||||
<span>{check.message}</span>
|
<span>{check.message}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
229
frontend/src/components/CompanyOverviewSetup.jsx
Normal file
229
frontend/src/components/CompanyOverviewSetup.jsx
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
import { ArrowRight, Check } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ id: 1, title: "Add domain", hint: "Use your company domain for email" },
|
||||||
|
{ id: 2, title: "Verify domain", hint: "Prove domain ownership" },
|
||||||
|
{ id: 3, title: "Configure email DNS", hint: "Set up MX, SPF, DKIM" },
|
||||||
|
{ id: 4, title: "Company details", hint: "Tell us about your organization" },
|
||||||
|
{ id: 5, title: "Add employees", hint: "Create and invite your team" },
|
||||||
|
{ id: 6, title: "Finish setup", hint: "Start using AGMail" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ORG_SIZES = [
|
||||||
|
"1-10 employees",
|
||||||
|
"11-50 employees",
|
||||||
|
"51-200 employees",
|
||||||
|
"201-500 employees",
|
||||||
|
"500+ employees",
|
||||||
|
];
|
||||||
|
|
||||||
|
const COUNTRIES = [
|
||||||
|
"India",
|
||||||
|
"United States",
|
||||||
|
"United Kingdom",
|
||||||
|
"Canada",
|
||||||
|
"Australia",
|
||||||
|
"Singapore",
|
||||||
|
"Other",
|
||||||
|
];
|
||||||
|
|
||||||
|
function storageKey(accountId, suffix) {
|
||||||
|
return `agmail_org_${suffix}_${accountId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStep(accountId) {
|
||||||
|
const saved = Number(localStorage.getItem(storageKey(accountId, "step")));
|
||||||
|
if (saved >= 4 && saved <= 6) return saved;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDetails(account, companyDomain) {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(storageKey(account.id, "details"));
|
||||||
|
if (raw) return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
companyName: companyDomain?.domain_name?.split(".")[0] || "",
|
||||||
|
orgSize: "51-200 employees",
|
||||||
|
country: "India",
|
||||||
|
contactName: `${account.first_name} ${account.last_name}`.trim(),
|
||||||
|
workEmail: account.email,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOrgSetupComplete(accountId) {
|
||||||
|
return localStorage.getItem(storageKey(accountId, "complete")) === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompanyOverviewSetup({ account, companyDomain, onComplete }) {
|
||||||
|
const [step, setStep] = useState(() => loadStep(account.id));
|
||||||
|
const [details, setDetails] = useState(() => loadDetails(account, companyDomain));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(storageKey(account.id, "step"), String(step));
|
||||||
|
}, [account.id, step]);
|
||||||
|
|
||||||
|
function saveDetails(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
localStorage.setItem(storageKey(account.id, "details"), JSON.stringify(details));
|
||||||
|
setStep(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishSetup() {
|
||||||
|
localStorage.setItem(storageKey(account.id, "complete"), "1");
|
||||||
|
localStorage.removeItem(storageKey(account.id, "step"));
|
||||||
|
onComplete?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="org-setup">
|
||||||
|
<header className="org-setup-head">
|
||||||
|
<h1>Set up your organization</h1>
|
||||||
|
<p>Get your company email up and running in a few simple steps.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="org-setup-layout">
|
||||||
|
<ol className="org-setup-steps" aria-label="Setup progress">
|
||||||
|
{STEPS.map((item) => {
|
||||||
|
const complete = item.id < step || (item.id <= 3);
|
||||||
|
const active = item.id === step;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className={`org-setup-step${complete ? " complete" : ""}${active ? " active" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="org-setup-step-mark" aria-hidden="true">
|
||||||
|
{complete ? <Check size={14} strokeWidth={2.5} /> : item.id}
|
||||||
|
</span>
|
||||||
|
<span className="org-setup-step-copy">
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
<small>{item.hint}</small>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="org-setup-panel">
|
||||||
|
{step === 4 && (
|
||||||
|
<>
|
||||||
|
<h2>Company details</h2>
|
||||||
|
<p className="org-setup-section-label">Organization information</p>
|
||||||
|
<form className="org-setup-form" onSubmit={saveDetails}>
|
||||||
|
<label>
|
||||||
|
Company name <span aria-hidden="true">*</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={details.companyName}
|
||||||
|
onChange={(event) => setDetails((current) => ({
|
||||||
|
...current,
|
||||||
|
companyName: event.target.value,
|
||||||
|
}))}
|
||||||
|
placeholder="Acme Pvt Ltd"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Organization size <span aria-hidden="true">*</span>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={details.orgSize}
|
||||||
|
onChange={(event) => setDetails((current) => ({
|
||||||
|
...current,
|
||||||
|
orgSize: event.target.value,
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
{ORG_SIZES.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Country <span aria-hidden="true">*</span>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={details.country}
|
||||||
|
onChange={(event) => setDetails((current) => ({
|
||||||
|
...current,
|
||||||
|
country: event.target.value,
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
{COUNTRIES.map((country) => (
|
||||||
|
<option key={country} value={country}>{country}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Your name <span aria-hidden="true">*</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={details.contactName}
|
||||||
|
onChange={(event) => setDetails((current) => ({
|
||||||
|
...current,
|
||||||
|
contactName: event.target.value,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Your work email <span aria-hidden="true">*</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
value={details.workEmail}
|
||||||
|
onChange={(event) => setDetails((current) => ({
|
||||||
|
...current,
|
||||||
|
workEmail: event.target.value,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="org-setup-actions">
|
||||||
|
<button type="submit" className="button button-purple">
|
||||||
|
Continue <ArrowRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 5 && (
|
||||||
|
<>
|
||||||
|
<h2>Add employees</h2>
|
||||||
|
<p className="org-setup-lead">
|
||||||
|
Invite teammates to <strong>{companyDomain?.domain_name}</strong>. You can add people now or skip and do this later from People.
|
||||||
|
</p>
|
||||||
|
<div className="org-setup-placeholder">
|
||||||
|
Employee invites will be available here soon.
|
||||||
|
</div>
|
||||||
|
<div className="org-setup-actions">
|
||||||
|
<button type="button" className="button button-purple" onClick={() => setStep(6)}>
|
||||||
|
Continue <ArrowRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 6 && (
|
||||||
|
<>
|
||||||
|
<h2>Finish setup</h2>
|
||||||
|
<p className="org-setup-lead">
|
||||||
|
Domain <strong>{companyDomain?.domain_name}</strong> is connected. You're ready to use AGMail.
|
||||||
|
</p>
|
||||||
|
<ul className="org-setup-summary">
|
||||||
|
<li><Check size={14} /> Domain added and verified</li>
|
||||||
|
<li><Check size={14} /> Email DNS configured</li>
|
||||||
|
<li><Check size={14} /> Organization details saved</li>
|
||||||
|
</ul>
|
||||||
|
<div className="org-setup-actions">
|
||||||
|
<button type="button" className="button button-purple" onClick={finishSetup}>
|
||||||
|
Go to inbox <ArrowRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
CircleDollarSign,
|
||||||
|
CircleEllipsis,
|
||||||
Clock3,
|
Clock3,
|
||||||
|
Crown,
|
||||||
FileEdit,
|
FileEdit,
|
||||||
|
Globe,
|
||||||
|
Home,
|
||||||
Inbox,
|
Inbox,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
|
@ -10,16 +15,20 @@ import {
|
||||||
Plug,
|
Plug,
|
||||||
Send,
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
|
Shield,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Star,
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Users,
|
||||||
|
UsersRound,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
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 CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup";
|
||||||
import ComposeModal from "./ComposeModal";
|
import ComposeModal from "./ComposeModal";
|
||||||
import MailboxView from "./MailboxView";
|
import MailboxView from "./MailboxView";
|
||||||
|
|
||||||
|
|
@ -49,6 +58,18 @@ const agentItems = [
|
||||||
[Plug, "Integrations", "integrations"],
|
[Plug, "Integrations", "integrations"],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const companyItems = [
|
||||||
|
[Home, "Overview"],
|
||||||
|
[Users, "People"],
|
||||||
|
[UsersRound, "Groups"],
|
||||||
|
[Globe, "Domains"],
|
||||||
|
[CircleEllipsis, "Aliases"],
|
||||||
|
[Shield, "Security"],
|
||||||
|
[Crown, "Administrators"],
|
||||||
|
[CircleDollarSign, "Billing"],
|
||||||
|
[Settings, "Settings"],
|
||||||
|
];
|
||||||
|
|
||||||
function clearSession() {
|
function clearSession() {
|
||||||
sessionStorage.removeItem("agmail_access_token");
|
sessionStorage.removeItem("agmail_access_token");
|
||||||
sessionStorage.removeItem("agmail_account");
|
sessionStorage.removeItem("agmail_account");
|
||||||
|
|
@ -82,6 +103,7 @@ export default function DashboardPage() {
|
||||||
const [lens, setLens] = useState("all");
|
const [lens, setLens] = useState("all");
|
||||||
const [agentNav, setAgentNav] = useState(null);
|
const [agentNav, setAgentNav] = useState(null);
|
||||||
const [labelsExpanded, setLabelsExpanded] = useState(false);
|
const [labelsExpanded, setLabelsExpanded] = useState(false);
|
||||||
|
const [companyActive, setCompanyActive] = useState(null);
|
||||||
const [mailStats, setMailStats] = useState({
|
const [mailStats, setMailStats] = useState({
|
||||||
unread: 0,
|
unread: 0,
|
||||||
starred: 0,
|
starred: 0,
|
||||||
|
|
@ -103,7 +125,7 @@ export default function DashboardPage() {
|
||||||
.then(async (currentAccount) => {
|
.then(async (currentAccount) => {
|
||||||
sessionStorage.setItem("agmail_account", JSON.stringify(currentAccount));
|
sessionStorage.setItem("agmail_account", JSON.stringify(currentAccount));
|
||||||
setAccount(currentAccount);
|
setAccount(currentAccount);
|
||||||
if (currentAccount.account_type === "company") {
|
if (currentAccount.account_type === "company" && currentAccount.is_domain_owner) {
|
||||||
try {
|
try {
|
||||||
const domain = await getMyCompanyDomain(token);
|
const domain = await getMyCompanyDomain(token);
|
||||||
setCompanyDomain(domain);
|
setCompanyDomain(domain);
|
||||||
|
|
@ -120,12 +142,26 @@ export default function DashboardPage() {
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!account?.is_domain_owner || needsDnsSetup) return;
|
||||||
|
if (!isOrgSetupComplete(account.id)) {
|
||||||
|
setCompanyActive("Overview");
|
||||||
|
}
|
||||||
|
}, [account, needsDnsSetup]);
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
clearSession();
|
clearSession();
|
||||||
window.location.assign("/login");
|
window.location.assign("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectCompanyItem(label) {
|
||||||
|
setCompanyActive(label);
|
||||||
|
setAgentNav(null);
|
||||||
|
setMenuOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
function selectMailItem(item) {
|
function selectMailItem(item) {
|
||||||
|
setCompanyActive(null);
|
||||||
setAgentNav(null);
|
setAgentNav(null);
|
||||||
if (item === "Starred") {
|
if (item === "Starred") {
|
||||||
setActive("Inbox");
|
setActive("Inbox");
|
||||||
|
|
@ -141,6 +177,7 @@ export default function DashboardPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectLabel(name) {
|
function selectLabel(name) {
|
||||||
|
setCompanyActive(null);
|
||||||
setAgentNav(null);
|
setAgentNav(null);
|
||||||
setActive("Inbox");
|
setActive("Inbox");
|
||||||
setLens(name);
|
setLens(name);
|
||||||
|
|
@ -148,6 +185,7 @@ export default function DashboardPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAgentItem(item, actionKey) {
|
function selectAgentItem(item, actionKey) {
|
||||||
|
setCompanyActive(null);
|
||||||
setActive("Inbox");
|
setActive("Inbox");
|
||||||
setLens("all");
|
setLens("all");
|
||||||
setAgentNav(actionKey);
|
setAgentNav(actionKey);
|
||||||
|
|
@ -190,6 +228,7 @@ export default function DashboardPage() {
|
||||||
onComplete={() => {
|
onComplete={() => {
|
||||||
setCompanyDomain({ ...companyDomain, status: "active" });
|
setCompanyDomain({ ...companyDomain, status: "active" });
|
||||||
setNeedsDnsSetup(false);
|
setNeedsDnsSetup(false);
|
||||||
|
setCompanyActive("Overview");
|
||||||
}}
|
}}
|
||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
/>
|
/>
|
||||||
|
|
@ -200,6 +239,7 @@ export default function DashboardPage() {
|
||||||
const workspaceTitle = account.account_type === "company" && companyDomain
|
const workspaceTitle = account.account_type === "company" && companyDomain
|
||||||
? companyDomain.domain_name
|
? companyDomain.domain_name
|
||||||
: "Personal workspace";
|
: "Personal workspace";
|
||||||
|
const isDomainOwner = account.account_type === "company" && account.is_domain_owner;
|
||||||
const mailActive = lens === "starred"
|
const mailActive = lens === "starred"
|
||||||
? "Starred"
|
? "Starred"
|
||||||
: active === "Outbox"
|
: active === "Outbox"
|
||||||
|
|
@ -236,6 +276,24 @@ export default function DashboardPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isDomainOwner && (
|
||||||
|
<SidebarSection title="Company" className="dashboard-sidebar-company dashboard-sidebar-first">
|
||||||
|
<nav aria-label="Company">
|
||||||
|
{companyItems.map(([ItemIcon, label]) => (
|
||||||
|
<button
|
||||||
|
className={`dashboard-nav-item dashboard-company-item${companyActive === label ? " active" : ""}`}
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectCompanyItem(label)}
|
||||||
|
>
|
||||||
|
<ItemIcon size={16} strokeWidth={1.75} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</SidebarSection>
|
||||||
|
)}
|
||||||
|
|
||||||
<SidebarSection title="Mail" className="dashboard-sidebar-mail">
|
<SidebarSection title="Mail" className="dashboard-sidebar-mail">
|
||||||
<nav aria-label="Mail">
|
<nav aria-label="Mail">
|
||||||
{mailItems.map(([ItemIcon, label, countKey]) => (
|
{mailItems.map(([ItemIcon, label, countKey]) => (
|
||||||
|
|
@ -303,13 +361,19 @@ export default function DashboardPage() {
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="dashboard-content">
|
<main className="dashboard-content">
|
||||||
{!["Inbox", "Outbox", "Trash"].includes(active) && (
|
{companyActive && companyActive !== "Overview" && (
|
||||||
|
<div className="dashboard-content-heading">
|
||||||
|
<div><p>{workspaceTitle}</p><h1>{companyActive}</h1></div>
|
||||||
|
<span className="dashboard-account-pill">{workspaceLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!companyActive && !["Inbox", "Outbox", "Trash"].includes(active) && (
|
||||||
<div className="dashboard-content-heading">
|
<div className="dashboard-content-heading">
|
||||||
<div><p>{workspaceTitle}</p><h1>{active}</h1></div>
|
<div><p>{workspaceTitle}</p><h1>{active}</h1></div>
|
||||||
<span className="dashboard-account-pill">{workspaceLabel}</span>
|
<span className="dashboard-account-pill">{workspaceLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{active === "Inbox" && (
|
{!companyActive && active === "Inbox" && (
|
||||||
<MailboxView
|
<MailboxView
|
||||||
mailbox="inbox"
|
mailbox="inbox"
|
||||||
title="Inbox"
|
title="Inbox"
|
||||||
|
|
@ -322,7 +386,7 @@ export default function DashboardPage() {
|
||||||
onStats={handleStats}
|
onStats={handleStats}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{active === "Outbox" && (
|
{!companyActive && active === "Outbox" && (
|
||||||
<MailboxView
|
<MailboxView
|
||||||
mailbox="sent"
|
mailbox="sent"
|
||||||
title="Sent"
|
title="Sent"
|
||||||
|
|
@ -332,7 +396,7 @@ export default function DashboardPage() {
|
||||||
onOpenSettings={() => selectMailItem("Settings")}
|
onOpenSettings={() => selectMailItem("Settings")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{active === "Trash" && (
|
{!companyActive && active === "Trash" && (
|
||||||
<MailboxView
|
<MailboxView
|
||||||
mailbox="trash"
|
mailbox="trash"
|
||||||
title="Trash"
|
title="Trash"
|
||||||
|
|
@ -342,7 +406,28 @@ export default function DashboardPage() {
|
||||||
onOpenSettings={() => selectMailItem("Settings")}
|
onOpenSettings={() => selectMailItem("Settings")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!["Inbox", "Outbox", "Trash"].includes(active) && (
|
{companyActive === "Overview" && isDomainOwner && (
|
||||||
|
<CompanyOverviewSetup
|
||||||
|
account={account}
|
||||||
|
companyDomain={companyDomain}
|
||||||
|
onComplete={() => {
|
||||||
|
setCompanyActive(null);
|
||||||
|
setActive("Inbox");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{companyActive && companyActive !== "Overview" && (
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{!companyActive && !["Inbox", "Outbox", "Trash"].includes(active) && (
|
||||||
<section className="dashboard-empty">
|
<section className="dashboard-empty">
|
||||||
<span><Sparkles size={24} /></span>
|
<span><Sparkles size={24} /></span>
|
||||||
<h2>{active} is ready for you</h2>
|
<h2>{active} is ready for you</h2>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export default function LoginPage() {
|
||||||
<>
|
<>
|
||||||
<div className="card-heading"><h2>Log in to agmail</h2><p>Enter your account details below.</p></div>
|
<div className="card-heading"><h2>Log in to agmail</h2><p>Enter your account details below.</p></div>
|
||||||
<form className="auth-form" onSubmit={handleSubmit}>
|
<form className="auth-form" onSubmit={handleSubmit}>
|
||||||
<label>Email address<div className="input-wrap"><Mail size={16} /><input name="email" type="email" placeholder="you@getall.help" required autoComplete="email" /></div></label>
|
<label>Email address<div className="input-wrap"><Mail size={16} /><input name="email" type="email" placeholder="you@gentic.email" required autoComplete="email" /></div></label>
|
||||||
<label>Password<div className="input-wrap"><LockKeyhole size={16} /><input name="password" type="password" placeholder="Your password" required autoComplete="current-password" /></div></label>
|
<label>Password<div className="input-wrap"><LockKeyhole size={16} /><input name="password" type="password" placeholder="Your password" required autoComplete="current-password" /></div></label>
|
||||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||||
<button className="button button-coral auth-submit" disabled={status.loading}>
|
<button className="button button-coral auth-submit" disabled={status.loading}>
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export default function SignupPage() {
|
||||||
<label>Company domain<div className="input-wrap"><Building2 size={16} /><input name="company_domain" placeholder="yourcompany.com" required /></div></label>
|
<label>Company domain<div className="input-wrap"><Building2 size={16} /><input name="company_domain" placeholder="yourcompany.com" required /></div></label>
|
||||||
)}
|
)}
|
||||||
<label>Choose your email name
|
<label>Choose your email name
|
||||||
<div className="email-builder input-wrap"><Mail size={16} /><input name="email_name" placeholder="alex" pattern="[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?" required /><span>{accountType === "individual" ? "@getall.help" : "@your domain"}</span></div>
|
<div className="email-builder input-wrap"><Mail size={16} /><input name="email_name" placeholder="alex" pattern="[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?" required /><span>{accountType === "individual" ? "@gentic.email" : "@your domain"}</span></div>
|
||||||
</label>
|
</label>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<label>First name<div className="input-wrap"><UserRound size={16} /><input name="first_name" placeholder="Alex" required /></div></label>
|
<label>First name<div className="input-wrap"><UserRound size={16} /><input name="first_name" placeholder="Alex" required /></div></label>
|
||||||
|
|
|
||||||
|
|
@ -210,8 +210,8 @@ export default function SmtpCredentialsPage() {
|
||||||
<input
|
<input
|
||||||
name="domain"
|
name="domain"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="getall.help"
|
placeholder="gentic.email"
|
||||||
defaultValue={result?.domain || "getall.help"}
|
defaultValue={result?.domain || "gentic.email"}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,41 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
||||||
.company-dns-check.ok { background:#eef8f1; color:#2f6b43; }
|
.company-dns-check.ok { background:#eef8f1; color:#2f6b43; }
|
||||||
.company-dns-check.fail { background:#fff0eb; color:#b04a38; }
|
.company-dns-check.fail { background:#fff0eb; color:#b04a38; }
|
||||||
.company-dns-next { align-self:flex-start; margin-top:8px; display:inline-flex; align-items:center; gap:8px; }
|
.company-dns-next { align-self:flex-start; margin-top:8px; display:inline-flex; align-items:center; gap:8px; }
|
||||||
|
.button-purple { min-height:44px; padding:0 20px; display:inline-flex; align-items:center; gap:8px; border:0; border-radius:10px; background:#7357F6; color:#fff; font-size:13px; font-weight:600; cursor:pointer; box-shadow:0 8px 20px rgba(115,87,246,0.28); }
|
||||||
|
.button-purple:hover { filter:brightness(1.03); }
|
||||||
|
.org-setup { padding:8px 0 24px; }
|
||||||
|
.org-setup-head { margin-bottom:28px; }
|
||||||
|
.org-setup-head h1 { margin:0 0 8px; font:700 clamp(28px,3vw,36px)/1.15 "DM Sans"; letter-spacing:-.6px; color:#1a1628; }
|
||||||
|
.org-setup-head p { margin:0; color:#77758A; font-size:14px; }
|
||||||
|
.org-setup-layout { display:grid; grid-template-columns:minmax(220px,280px) minmax(0,1fr); gap:28px; align-items:start; }
|
||||||
|
.org-setup-steps { margin:0; padding:0; list-style:none; display:grid; gap:0; }
|
||||||
|
.org-setup-step { padding:12px 14px; display:grid; grid-template-columns:32px 1fr; gap:12px; align-items:start; border-radius:12px; color:#6f6c7b; }
|
||||||
|
.org-setup-step-mark { width:32px; height:32px; display:grid; place-items:center; border-radius:50%; border:1px solid #E4E1E8; background:#fff; font-size:13px; font-weight:600; color:#6f6c7b; }
|
||||||
|
.org-setup-step-copy { display:grid; gap:2px; }
|
||||||
|
.org-setup-step-copy strong { font-size:13px; font-weight:600; color:#3d3948; }
|
||||||
|
.org-setup-step-copy small { font-size:11px; color:#8d8792; line-height:1.35; }
|
||||||
|
.org-setup-step.complete .org-setup-step-mark { border-color:#7357F6; background:#7357F6; color:#fff; }
|
||||||
|
.org-setup-step.complete .org-setup-step-copy strong { color:#5a4fcf; }
|
||||||
|
.org-setup-step.active { background:#F6F2FF; }
|
||||||
|
.org-setup-step.active .org-setup-step-mark { border-color:#7357F6; background:#7357F6; color:#fff; }
|
||||||
|
.org-setup-step.active .org-setup-step-copy strong { color:#5a4fcf; }
|
||||||
|
.org-setup-panel { padding:28px 32px; border:1px solid #ECE9F2; border-radius:16px; background:#fff; box-shadow:0 8px 28px rgba(20,18,30,0.04); }
|
||||||
|
.org-setup-panel h2 { margin:0 0 18px; font:700 22px/1.2 "DM Sans"; color:#1a1628; }
|
||||||
|
.org-setup-section-label { margin:0 0 16px; font-size:12px; font-weight:600; color:#8d8792; }
|
||||||
|
.org-setup-lead { margin:0 0 20px; color:#6f6c7b; font-size:14px; line-height:1.5; }
|
||||||
|
.org-setup-form { display:grid; gap:14px; }
|
||||||
|
.org-setup-form label { display:grid; gap:6px; font-size:12px; font-weight:600; color:#4a4652; }
|
||||||
|
.org-setup-form label span { color:#e15b45; }
|
||||||
|
.org-setup-form input,.org-setup-form select { height:42px; padding:0 12px; border:1px solid #E1DFE7; border-radius:10px; background:#fff; font-size:13px; color:#1a1628; }
|
||||||
|
.org-setup-form input:focus,.org-setup-form select:focus { outline:none; border-color:#C8B8FF; box-shadow:0 0 0 3px rgba(115,87,246,0.12); }
|
||||||
|
.org-setup-actions { margin-top:8px; display:flex; justify-content:flex-end; }
|
||||||
|
.org-setup-placeholder { margin-bottom:20px; padding:24px; border:1px dashed #E4E1E8; border-radius:12px; background:#FAFAFC; color:#8d8792; font-size:13px; text-align:center; }
|
||||||
|
.org-setup-summary { margin:0 0 24px; padding:0; list-style:none; display:grid; gap:10px; }
|
||||||
|
.org-setup-summary li { display:flex; align-items:center; gap:8px; color:#3d3948; font-size:13px; font-weight:500; }
|
||||||
|
.org-setup-summary svg { color:#7357F6; flex-shrink:0; }
|
||||||
|
@media(max-width:900px) {
|
||||||
|
.org-setup-layout { grid-template-columns:1fr; }
|
||||||
|
}
|
||||||
.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; }
|
||||||
|
|
@ -471,7 +506,9 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
||||||
.label-dot-green { background:#8ed9ad; }
|
.label-dot-green { background:#8ed9ad; }
|
||||||
.dashboard-agent-item { color:#c3bed0; }
|
.dashboard-agent-item { color:#c3bed0; }
|
||||||
.dashboard-agent-item.active { background:linear-gradient(90deg,#8b7cff22 0%,#ffffff10 100%); box-shadow:inset 3px 0 0 #9f8cff; }
|
.dashboard-agent-item.active { background:linear-gradient(90deg,#8b7cff22 0%,#ffffff10 100%); box-shadow:inset 3px 0 0 #9f8cff; }
|
||||||
.dashboard-sidebar-mail,.dashboard-sidebar-labels,.dashboard-sidebar-agent { flex:0 0 auto; }
|
.dashboard-sidebar-mail,.dashboard-sidebar-labels,.dashboard-sidebar-agent,.dashboard-sidebar-company { flex:0 0 auto; }
|
||||||
|
.dashboard-sidebar-company.dashboard-sidebar-first { margin-top:var(--space-4); padding-top:0; border-top:0; }
|
||||||
|
.dashboard-company-item.active { background:rgba(255,255,255,0.09); box-shadow:inset 2px 0 0 #a78bfa; }
|
||||||
.dashboard-profile { margin-top:auto; padding-top:var(--space-3); border-top:1px solid #ffffff10; flex:0 0 auto; }
|
.dashboard-profile { margin-top:auto; padding-top:var(--space-3); border-top:1px solid #ffffff10; flex:0 0 auto; }
|
||||||
.dashboard-profile>span { background:linear-gradient(180deg,#ff9a82,#ff6f57); color:#2a2038; }
|
.dashboard-profile>span { background:linear-gradient(180deg,#ff9a82,#ff6f57); color:#2a2038; }
|
||||||
.dashboard-profile strong { color:#fff; }
|
.dashboard-profile strong { color:#fff; }
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
: "${MAIL_HOSTNAME:=mail.getall.help}"
|
: "${MAIL_HOSTNAME:=mail.gentic.email}"
|
||||||
: "${MAIL_DOMAIN:=getall.help}"
|
: "${MAIL_DOMAIN:=gentic.email}"
|
||||||
: "${INBOUND_DOMAINS:=${MAIL_DOMAIN}}"
|
: "${INBOUND_DOMAINS:=${MAIL_DOMAIN}}"
|
||||||
: "${RELAY_DOMAINS_FILE:=/data/relay_domains/domains.list}"
|
: "${RELAY_DOMAINS_FILE:=/data/relay_domains/domains.list}"
|
||||||
: "${RELAY_DOMAINS_REFRESH:=60}"
|
: "${RELAY_DOMAINS_REFRESH:=60}"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Internet-facing inbound relay + trusted outbound for Agmail.
|
# Internet-facing inbound relay + trusted outbound for Agmail.
|
||||||
compatibility_level = 3.6
|
compatibility_level = 3.6
|
||||||
myhostname = mail.getall.help
|
myhostname = mail.gentic.email
|
||||||
mydomain = getall.help
|
mydomain = gentic.email
|
||||||
myorigin = $mydomain
|
myorigin = $mydomain
|
||||||
inet_interfaces = all
|
inet_interfaces = all
|
||||||
inet_protocols = ipv4
|
inet_protocols = ipv4
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue