domain migration

This commit is contained in:
debu-2 2026-10-04 14:40:06 +05:30
parent 192b810eb4
commit c04a6ce6fe
26 changed files with 777 additions and 125 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View 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`.

View file

@ -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:&lt;mail-server-ip&gt; ~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>

View 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&apos;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>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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