diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 414f1a9..bfa4a1b 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -3,25 +3,25 @@ Agmail runs as one Docker Compose stack on a **mail VPS** (port 25 open): Caddy (HTTPS), frontend, backend, PostgreSQL, MongoDB, and Postfix (inbound + outbound). -The **main website** at `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: | Hostname | Points to | Purpose | |----------|-----------|---------| -| `getall.help` | GCP / main site IP | Marketing / main app (unchanged) | -| `email.getall.help` | **Mail VPS public IP** | Agmail web UI (Caddy → frontend) | -| `mail.getall.help` | **Mail VPS public IP** | Postfix :25 (inbound + outbound) | -| `@getall.help` MX | `mail.getall.help` | Delivers mail to the VPS | +| `gentic.email` | GCP / main site IP | Marketing / main app (unchanged) | +| `email.gentic.email` | **Mail VPS public IP** | Agmail web UI (Caddy → frontend) | +| `mail.gentic.email` | **Mail VPS public IP** | Postfix :25 (inbound + outbound) | +| `@gentic.email` MX | `mail.gentic.email` | Delivers mail to the VPS | -User addresses remain **`user@getall.help`**. +User addresses remain **`user@gentic.email`**. ## Architecture ```text -Browser ──HTTPS──► email.getall.help (Caddy :443) +Browser ──HTTPS──► email.gentic.email (Caddy :443) └──► 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 └── outbound ← backend (Docker mynetworks) ``` @@ -34,7 +34,7 @@ cp .env.example .env 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 | |----------|-----------|---------| @@ -60,8 +60,8 @@ MONGO_ROOT_PASSWORD="" GCS_RAW_MIME_BUCKET= GCS_PROJECT= GCP_CREDENTIALS_FILE=./midyear-karma-470610-i1-da5334d20acc.json -CADDY_ACME_EMAIL=admin@getall.help -CORS_ORIGINS=["https://email.getall.help"] +CADDY_ACME_EMAIL=admin@gentic.email +CORS_ORIGINS=["https://email.gentic.email"] ``` Quote values that contain `#`. @@ -74,18 +74,18 @@ Outbound uses the Docker Postfix service by default (`SMTP_HOST=smtp`, ### Split-domain settings (mail VPS) ```env -# User mailboxes are still @getall.help -PLATFORM_DOMAIN=getall.help +# User mailboxes are still @gentic.email +PLATFORM_DOMAIN=gentic.email # Web UI on this host (Caddy terminates TLS here) -APP_PUBLIC_HOST=email.getall.help -CADDY_ACME_EMAIL=admin@getall.help +APP_PUBLIC_HOST=email.gentic.email +CADDY_ACME_EMAIL=admin@gentic.email # Postfix hostname (inbound + outbound) -MAIL_HOSTNAME=mail.getall.help -MAIL_DOMAIN=getall.help -INBOUND_DOMAINS=getall.help -SMTP_PUBLIC_HOST=mail.getall.help +MAIL_HOSTNAME=mail.gentic.email +MAIL_DOMAIN=gentic.email +INBOUND_DOMAINS=gentic.email +SMTP_PUBLIC_HOST=mail.gentic.email SMTP_HOST=smtp SMTP_PORT=25 SMTP_USE_TLS=false @@ -100,7 +100,7 @@ INBOUND_SMTP_BIND_IP=127.0.0.1 ## Namecheap DNS changes Update DNS as follows (`` = 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 @@ -109,16 +109,16 @@ exact rows at **https://email.getall.help/smtp-credentials**: | **A** | `@` | main site IP | **Keep** — marketing site | | **A** | `email` | `` | Web UI | | **A** | `mail` | `` | Postfix | -| **MX** | `@` | `10 mail.getall.help` | Priority 10 | +| **MX** | `@` | `10 mail.gentic.email` | Priority 10 | ### Outbound authentication | Type | Host | Value | Notes | |------|------|-------|-------| -| **TXT** | `@` | `v=spf1 mx a ip4: ~all` | SPF for `@getall.help` mail | +| **TXT** | `@` | `v=spf1 mx a ip4: ~all` | SPF for `@gentic.email` mail | | **TXT** | `_spf` | `v=spf1 ip4: ~all` | **Required** — company domains SPF-include this | | **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. @@ -140,22 +140,22 @@ nc -vz -w 5 gmail-smtp-in.l.google.com 25 ### 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 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 / -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) Gmail/Zoho use STARTTLS. Postfix generates a cert on first boot. After rebuild: ```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) ``` @@ -163,7 +163,7 @@ printf 'EHLO test\r\nSTARTTLS\r\n' | nc mail.getall.help 25 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 3. `CADDY_ACME_EMAIL` is set in `.env` @@ -173,13 +173,13 @@ Config: `deploy/caddy/Caddyfile` → reverse-proxies to `frontend:80`. ```bash # HTTPS UI -curl -fsSI https://email.getall.help/health +curl -fsSI https://email.gentic.email/health # Inbound mail (account must exist) swaks \ - --server mail.getall.help:25 \ + --server mail.gentic.email:25 \ --from sender@example.com \ - --to existing-user@getall.help \ + --to existing-user@gentic.email \ --header "Subject: inbound smoke test" \ --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) grep CADDY_ACME_EMAIL .env grep APP_PUBLIC_HOST .env -# APP_PUBLIC_HOST=email.getall.help +# APP_PUBLIC_HOST=email.gentic.email # CADDY_ACME_EMAIL=you@gmail.com # 2. Check Caddy logs for ACME errors @@ -227,12 +227,12 @@ docker compose logs -f caddy Wait 30–60 seconds, then test: ```bash -curl -fsSI https://email.getall.help/ +curl -fsSI https://email.gentic.email/ ``` 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` - `CADDY_ACME_EMAIL` set in `.env` diff --git a/backend/app/config.py b/backend/app/config.py index a15cb91..a4a61e9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -21,15 +21,15 @@ class Settings(BaseSettings): jwt_secret: str = "replace-this-development-secret" access_token_minutes: int = 60 - platform_domain: str = "getall.help" - platform_spf_include: str = "_spf.getall.help" - app_public_host: str = "email.getall.help" + platform_domain: str = "gentic.email" + platform_spf_include: str = "_spf.gentic.email" + app_public_host: str = "email.gentic.email" dkim_selector: str = "agmail" # Outbound via self-hosted Postfix (Docker service `smtp` by default) smtp_host: str = "smtp" smtp_port: int = 25 - smtp_public_host: str = "mail.getall.help" + smtp_public_host: str = "mail.gentic.email" smtp_username: str = "" smtp_password: str = "" smtp_use_tls: bool = False diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index eb95060..39e0c4a 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -15,6 +15,8 @@ from app.config import get_settings from app.database import get_postgres_session from app.models import Account 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 ( get_domain_by_name, provision_mail_domain, @@ -50,7 +52,7 @@ def validate_password(value: str) -> None: def build_email(payload: AccountRegister) -> tuple[str, str | None]: - domain = "getall.help" + domain = settings.platform_domain company_domain = None 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, detail="Company domain is required for company accounts.", ) - domain = payload.company_domain.strip().lower().removeprefix("@") - if not domain_pattern.fullmatch(domain): + try: + domain = assert_company_domain_allowed( + payload.company_domain, + settings.platform_domain, + ) + except ValueError as error: raise HTTPException( 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 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( credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), session: AsyncSession = Depends(get_postgres_session), @@ -180,7 +201,7 @@ async def register( await session.refresh(account) return AuthResponse( 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( access_token=create_access_token(account), - account=AccountRead.model_validate(account), + account=await account_read(session, account), ) @router.get("/me", response_model=AccountRead) -async def get_me(account: Account = Depends(get_current_account)) -> Account: - return account +async def get_me( + account: Account = Depends(get_current_account), + session: AsyncSession = Depends(get_postgres_session), +) -> AccountRead: + return await account_read(session, account) diff --git a/backend/app/routers/domains.py b/backend/app/routers/domains.py index b6ab315..a16e9bf 100644 --- a/backend/app/routers/domains.py +++ b/backend/app/routers/domains.py @@ -18,9 +18,11 @@ from app.schemas import ( SmtpCredentialRead, ) 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 ( activate_company_domain, create_smtp_credential, + ensure_ownership_dns_record, ensure_platform_domain, get_account_company_domain, provision_mail_domain, @@ -102,12 +104,14 @@ async def verify_my_company_dns( checks=[], ) + verification_token = await ensure_ownership_dns_record(session, domain) checks = verify_company_dns( domain_name=domain.domain_name, shared_mail_host=settings.smtp_public_host, dkim_selector=domain.dkim_selector, dkim_public_key=domain.dkim_public_key, platform_spf_include=settings.platform_spf_include, + verification_token=verification_token, ) verified = all(check["verified"] for check in checks) if verified: @@ -151,19 +155,16 @@ async def register_company_domain( ) -> MailDomain: settings = get_settings() try: - normalized = validate_domain(payload.domain_name) + normalized = assert_company_domain_allowed( + payload.domain_name, + settings.platform_domain, + ) except ValueError as error: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(error), ) from error - 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( select(MailDomain).where(MailDomain.domain_name == normalized) ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 4637e44..21538af 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -34,6 +34,7 @@ class AccountRead(BaseModel): date_of_birth: date phone_number: str company_domain: str | None + is_domain_owner: bool = False created_at: datetime model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/services/dns_verification.py b/backend/app/services/dns_verification.py index cd27243..27c2030 100644 --- a/backend/app/services/dns_verification.py +++ b/backend/app/services/dns_verification.py @@ -5,6 +5,8 @@ from __future__ import annotations import dns.exception import dns.resolver +OWNERSHIP_PREFIX = "agmail-verify=" + def _normalize_txt(value: str) -> str: return " ".join(value.split()).strip().lower() @@ -51,6 +53,29 @@ def _lookup_mx(domain_name: str) -> list[tuple[int, str]]: 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]: expected = expected_host.rstrip(".").lower() 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: return ( 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: ~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}." expected = _normalize_txt(expected_value) for record in records: - if record == expected or record.startswith("v=dmarc1"): - if "v=dmarc1" in record: - return True, "DMARC record is present." - return False, "DMARC TXT record is missing or incomplete." + if record == expected: + return True, "DMARC record matches the expected value." + return ( + 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( @@ -151,15 +179,14 @@ def verify_company_dns( dkim_selector: str, dkim_public_key: str, platform_spf_include: str, + verification_token: str, ) -> list[dict]: + """Customer-domain checks only (ownership + mail authentication).""" dmarc_expected = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}" checks = [ + ("ownership", *verify_ownership(domain_name, verification_token)), ("mx", *verify_mx(domain_name, shared_mail_host)), ("spf", *verify_spf(domain_name, platform_spf_include)), - ( - "platform_spf", - *verify_platform_spf(platform_spf_include, shared_mail_host), - ), ( "dkim", *verify_dkim(domain_name, dkim_selector, dkim_public_key), diff --git a/backend/app/services/domain_policy.py b/backend/app/services/domain_policy.py new file mode 100644 index 0000000..1ca9dff --- /dev/null +++ b/backend/app/services/domain_policy.py @@ -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 diff --git a/backend/app/services/domain_provisioning.py b/backend/app/services/domain_provisioning.py index 9dec802..5b56f66 100644 --- a/backend/app/services/domain_provisioning.py +++ b/backend/app/services/domain_provisioning.py @@ -94,11 +94,21 @@ def build_company_dns_records( dkim_selector: str, dkim_public_key: str, platform_spf_include: str, + verification_token: str, ) -> 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}" dmarc_value = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}" return [ + { + "record_type": "TXT", + "host": namecheap_host("_agmail-verify", domain_name), + "value": ownership_record_value(verification_token), + "purpose": "ownership", + "ttl": 3600, + }, { "record_type": "MX", "host": namecheap_host("@", domain_name), @@ -273,12 +283,14 @@ async def provision_mail_domain( private_key, public_key = generate_dkim_keys() if domain_type == "company": mail_host = settings.smtp_public_host + verification_token = secrets.token_urlsafe(24) dns_records = build_company_dns_records( normalized, settings.smtp_public_host, dkim_selector, public_key, settings.platform_spf_include, + verification_token, ) else: dns_records = build_dns_records( @@ -331,6 +343,48 @@ async def activate_company_domain(session: AsyncSession, domain: MailDomain) -> 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( session: AsyncSession, account_id, diff --git a/backend/tests/test_company_dns.py b/backend/tests/test_company_dns.py index 28d5b86..5cb6777 100644 --- a/backend/tests/test_company_dns.py +++ b/backend/tests/test_company_dns.py @@ -2,8 +2,16 @@ 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 ( + account_is_domain_owner, build_company_dns_records, build_shared_mx_spf_value, 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(): records = build_company_dns_records( "acme.com", - "mail.getall.help", + "mail.gentic.email", "agmail", "abc123public", - "_spf.getall.help", + "_spf.gentic.email", + "verify-token-123", ) 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") spf = next(record for record in records if record["purpose"] == "spf") 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["value"] == "mail.getall.help" + assert mx["value"] == "mail.gentic.email" 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 "abc123public" in dkim["value"] assert not any(record.get("purpose") == "mail_host" for record in records) def test_build_shared_mx_spf_value(): - assert build_shared_mx_spf_value("_spf.getall.help") == ( - "v=spf1 include:_spf.getall.help ~all" + assert build_shared_mx_spf_value("_spf.gentic.email") == ( + "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() -@patch("app.services.dns_verification._lookup_a") -@patch("app.services.dns_verification._lookup_mx") +def test_assert_company_domain_allowed_blocks_platform_and_google(): + 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") -def test_verify_company_dns_all_pass(mock_txt, mock_mx, mock_a): - mock_mx.return_value = [(10, "mail.getall.help")] - mock_a.return_value = ["38.180.106.242"] +def test_verify_dmarc_requires_exact_match(mock_txt): + expected = "v=DMARC1; p=quarantine; rua=mailto:dmarc@acme.com" + 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: { - "acme.com": ['v=spf1 include:_spf.getall.help ~all'], - "_spf.getall.help": ["v=spf1 ip4:38.180.106.242 ~all"], + "_agmail-verify.acme.com": [ownership_record_value("token-abc")], + "acme.com": ['v=spf1 include:_spf.gentic.email ~all'], "agmail._domainkey.acme.com": ["v=DKIM1; k=rsa; p=abc123"], "_dmarc.acme.com": ["v=DMARC1; p=quarantine; rua=mailto:dmarc@acme.com"], }.get(name, []) checks = verify_company_dns( domain_name="acme.com", - shared_mail_host="mail.getall.help", + shared_mail_host="mail.gentic.email", dkim_selector="agmail", 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 [check["purpose"] for check in checks] == [ + "ownership", "mx", "spf", - "platform_spf", "dkim", "dmarc", ] diff --git a/backend/tests/test_dns_setup.py b/backend/tests/test_dns_setup.py index e12438f..962e99f 100644 --- a/backend/tests/test_dns_setup.py +++ b/backend/tests/test_dns_setup.py @@ -9,18 +9,18 @@ from app.services.domain_provisioning import ( def test_namecheap_host_strips_zone_suffix(): - assert namecheap_host("mail.getall.help", "getall.help") == "mail" - assert namecheap_host("@", "getall.help") == "@" + assert namecheap_host("mail.gentic.email", "gentic.email") == "mail" + assert namecheap_host("@", "gentic.email") == "@" assert ( - namecheap_host("agmail._domainkey.getall.help", "getall.help") + namecheap_host("agmail._domainkey.gentic.email", "gentic.email") == "agmail._domainkey" ) def test_build_dns_records_uses_namecheap_hosts_and_ip(): records = build_dns_records( - "getall.help", - "mail.getall.help", + "gentic.email", + "mail.gentic.email", "agmail", "abc123", 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") assert mx["host"] == "@" - assert mx["value"] == "mail.getall.help" + assert mx["value"] == "mail.gentic.email" assert mx["priority"] == 10 assert a_record["host"] == "mail" 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(): sections = build_deployment_dns_records( - domain_name="getall.help", + domain_name="gentic.email", mail_server_ip="203.0.113.10", dkim_selector="agmail", 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"]] @@ -67,7 +67,7 @@ def test_deployment_sections_split_inbound_and_outbound(): dmarc = next( 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( record for record in sections["outbound"] if record["purpose"] == "dkim" diff --git a/backend/tests/test_email_helpers.py b/backend/tests/test_email_helpers.py index af84767..ccde8db 100644 --- a/backend/tests/test_email_helpers.py +++ b/backend/tests/test_email_helpers.py @@ -24,7 +24,7 @@ def test_summary_uses_account_mailbox_read_state(): "_id": message_id, "direction": "inbound", "sender": {"name": "Sender", "address": "sender@example.com"}, - "to": [{"name": "user", "address": "user@getall.help"}], + "to": [{"name": "user", "address": "user@gentic.email"}], "subject": "Hello", "snippet": "A short preview", "status": "received", diff --git a/backend/tests/test_inbound_smtp.py b/backend/tests/test_inbound_smtp.py index 6f25798..df1142a 100644 --- a/backend/tests/test_inbound_smtp.py +++ b/backend/tests/test_inbound_smtp.py @@ -9,7 +9,7 @@ from app.services.raw_mime_storage import StoredMime RAW = ( b"From: Sender \r\n" - b"To: user@getall.help\r\n" + b"To: user@gentic.email\r\n" b"Subject: Hello\r\n\r\n" b"Retry safe body\r\n" ) @@ -57,9 +57,9 @@ class FakeSession: def envelope(raw=RAW): return SimpleNamespace( original_content=raw, - rcpt_tos=["user@getall.help"], + rcpt_tos=["user@gentic.email"], 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, 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 target.rcpt_tos == ["user@getall.help"] - assert target.account_ids == {"user@getall.help": "account-1"} + assert target.rcpt_tos == ["user@gentic.email"] + assert target.account_ids == {"user@gentic.email": "account-1"} @pytest.mark.asyncio diff --git a/backend/tests/test_mime_parser.py b/backend/tests/test_mime_parser.py index 3a9e7a9..e664599 100644 --- a/backend/tests/test_mime_parser.py +++ b/backend/tests/test_mime_parser.py @@ -4,7 +4,7 @@ from app.services.mime_parser import parse_mime def test_parse_mime_extracts_fallback_sender_bodies_and_attachments(): raw = ( b"From: sender@example.com\r\n" - b"To: User \r\n" + b"To: User \r\n" b"Subject: Production test\r\n" b"Message-ID: \r\n" b"MIME-Version: 1.0\r\n" diff --git a/deploy/app/docker-compose.yml b/deploy/app/docker-compose.yml index a285cea..035c0c7 100644 --- a/deploy/app/docker-compose.yml +++ b/deploy/app/docker-compose.yml @@ -61,13 +61,13 @@ services: MONGO_URL: mongodb://${MONGO_ROOT_USER:-agmail}:${MONGO_ROOT_PASSWORD:-agmail_dev_password}@mongodb:27017/?authSource=admin MONGO_DATABASE: ${MONGO_DATABASE:-agmail_messages} JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET to a long random value} - PLATFORM_DOMAIN: ${PLATFORM_DOMAIN:-getall.help} - PLATFORM_SPF_INCLUDE: ${PLATFORM_SPF_INCLUDE:-_spf.getall.help} - APP_PUBLIC_HOST: ${APP_PUBLIC_HOST:-email.getall.help} + PLATFORM_DOMAIN: ${PLATFORM_DOMAIN:-gentic.email} + PLATFORM_SPF_INCLUDE: ${PLATFORM_SPF_INCLUDE:-_spf.gentic.email} + APP_PUBLIC_HOST: ${APP_PUBLIC_HOST:-email.gentic.email} DKIM_SELECTOR: ${DKIM_SELECTOR:-agmail} SMTP_HOST: ${SMTP_HOST:-smtp} 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_PASSWORD: ${SMTP_PASSWORD:-} SMTP_USE_TLS: ${SMTP_USE_TLS:-false} diff --git a/deploy/caddy/docker-compose.yml b/deploy/caddy/docker-compose.yml index 1609802..536c646 100644 --- a/deploy/caddy/docker-compose.yml +++ b/deploy/caddy/docker-compose.yml @@ -11,7 +11,7 @@ services: - "${CADDY_HTTPS_PORT:-443}:443" - "${CADDY_HTTPS_PORT:-443}:443/udp" 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} env_file: - ../../.env diff --git a/deploy/smtp/docker-compose.yml b/deploy/smtp/docker-compose.yml index a6fa203..fab72be 100644 --- a/deploy/smtp/docker-compose.yml +++ b/deploy/smtp/docker-compose.yml @@ -10,11 +10,11 @@ services: context: ../../smtp container_name: agmail-smtp restart: unless-stopped - hostname: ${MAIL_HOSTNAME:-mail.getall.help} + hostname: ${MAIL_HOSTNAME:-mail.gentic.email} environment: - MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.getall.help} - MAIL_DOMAIN: ${MAIL_DOMAIN:-getall.help} - INBOUND_DOMAINS: ${INBOUND_DOMAINS:-getall.help} + MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.gentic.email} + MAIL_DOMAIN: ${MAIL_DOMAIN:-gentic.email} + INBOUND_DOMAINS: ${INBOUND_DOMAINS:-gentic.email} RELAY_DOMAINS_FILE: /data/relay_domains/domains.list RELAY_DOMAINS_REFRESH: ${RELAY_DOMAINS_REFRESH:-60} BACKEND_INGEST_HOST: ${BACKEND_INGEST_HOST:-backend} diff --git a/docs/GENTIC_EMAIL_DNS.md b/docs/GENTIC_EMAIL_DNS.md new file mode 100644 index 0000000..1a243d4 --- /dev/null +++ b/docs/GENTIC_EMAIL_DNS.md @@ -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` | `` | Mail server | +| **A** | `email` | `` | Web UI | +| **MX** | `@` | `10 mail.gentic.email` | Platform inbox delivery | + +### Outbound authentication (platform domain) + +| Type | Host | Value | +|------|------|-------| +| **TXT** | `_spf` | `v=spf1 ip4: ~all` | +| **TXT** | `@` | `v=spf1 mx a ip4: ~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 `` → `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=` (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`. diff --git a/frontend/src/components/CompanyDnsSetup.jsx b/frontend/src/components/CompanyDnsSetup.jsx index eaa205b..be47332 100644 --- a/frontend/src/components/CompanyDnsSetup.jsx +++ b/frontend/src/components/CompanyDnsSetup.jsx @@ -3,6 +3,14 @@ import { useState } from "react"; import { verifyCompanyDns } from "../api"; import Brand from "./Brand"; +const CHECK_LABELS = { + ownership: "Domain ownership", + mx: "MX", + spf: "SPF", + dkim: "DKIM", + dmarc: "DMARC", +}; + function DnsRecordTable({ records }) { async function copyValue(value) { try { @@ -99,8 +107,16 @@ export default function CompanyDnsSetup({ domain, account, onComplete, onLogout
    +
  • + Add the ownership TXT first — this proves you control the domain. + Without it, verification cannot succeed even if MX/SPF look correct. +
  • Point MX to {domain.mail_host} — you do not need a separate mail A record.
  • -
  • SPF uses include:_spf.getall.help — that record must exist on getall.help (Host _spf, TXT v=spf1 ip4:<mail-server-ip> ~all).
  • +
  • + SPF uses include:_spf.gentic.email. The platform operator maintains + _spf on gentic.email; you only add SPF on your domain. +
  • +
  • DMARC must match the exact value in the table — an existing third-party DMARC record is not accepted.
  • DNS changes can take a few minutes to propagate.
@@ -113,7 +129,7 @@ export default function CompanyDnsSetup({ domain, account, onComplete, onLogout > {check.verified ? : }
- {check.purpose.toUpperCase()} + {CHECK_LABELS[check.purpose] || check.purpose.toUpperCase()} {check.message}
diff --git a/frontend/src/components/CompanyOverviewSetup.jsx b/frontend/src/components/CompanyOverviewSetup.jsx new file mode 100644 index 0000000..987020b --- /dev/null +++ b/frontend/src/components/CompanyOverviewSetup.jsx @@ -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 ( +
+
+

Set up your organization

+

Get your company email up and running in a few simple steps.

+
+ +
+
    + {STEPS.map((item) => { + const complete = item.id < step || (item.id <= 3); + const active = item.id === step; + return ( +
  1. + + + {item.title} + {item.hint} + +
  2. + ); + })} +
+ +
+ {step === 4 && ( + <> +

Company details

+

Organization information

+
+ + + + + +
+ +
+
+ + )} + + {step === 5 && ( + <> +

Add employees

+

+ Invite teammates to {companyDomain?.domain_name}. You can add people now or skip and do this later from People. +

+
+ Employee invites will be available here soon. +
+
+ +
+ + )} + + {step === 6 && ( + <> +

Finish setup

+

+ Domain {companyDomain?.domain_name} is connected. You're ready to use AGMail. +

+
    +
  • Domain added and verified
  • +
  • Email DNS configured
  • +
  • Organization details saved
  • +
+
+ +
+ + )} +
+
+
+ ); +} diff --git a/frontend/src/components/DashboardPage.jsx b/frontend/src/components/DashboardPage.jsx index 3e86348..baebf1b 100644 --- a/frontend/src/components/DashboardPage.jsx +++ b/frontend/src/components/DashboardPage.jsx @@ -1,7 +1,12 @@ import { ChevronDown, + CircleDollarSign, + CircleEllipsis, Clock3, + Crown, FileEdit, + Globe, + Home, Inbox, ListChecks, LogOut, @@ -10,16 +15,20 @@ import { Plug, Send, Settings, + Shield, ShieldAlert, Sparkles, Star, Trash2, + Users, + UsersRound, X, } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { getCurrentAccount, getMyCompanyDomain } from "../api"; import Brand from "./Brand"; import CompanyDnsSetup from "./CompanyDnsSetup"; +import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup"; import ComposeModal from "./ComposeModal"; import MailboxView from "./MailboxView"; @@ -49,6 +58,18 @@ const agentItems = [ [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() { sessionStorage.removeItem("agmail_access_token"); sessionStorage.removeItem("agmail_account"); @@ -82,6 +103,7 @@ export default function DashboardPage() { const [lens, setLens] = useState("all"); const [agentNav, setAgentNav] = useState(null); const [labelsExpanded, setLabelsExpanded] = useState(false); + const [companyActive, setCompanyActive] = useState(null); const [mailStats, setMailStats] = useState({ unread: 0, starred: 0, @@ -103,7 +125,7 @@ export default function DashboardPage() { .then(async (currentAccount) => { sessionStorage.setItem("agmail_account", JSON.stringify(currentAccount)); setAccount(currentAccount); - if (currentAccount.account_type === "company") { + if (currentAccount.account_type === "company" && currentAccount.is_domain_owner) { try { const domain = await getMyCompanyDomain(token); 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() { clearSession(); window.location.assign("/login"); } + function selectCompanyItem(label) { + setCompanyActive(label); + setAgentNav(null); + setMenuOpen(false); + } + function selectMailItem(item) { + setCompanyActive(null); setAgentNav(null); if (item === "Starred") { setActive("Inbox"); @@ -141,6 +177,7 @@ export default function DashboardPage() { } function selectLabel(name) { + setCompanyActive(null); setAgentNav(null); setActive("Inbox"); setLens(name); @@ -148,6 +185,7 @@ export default function DashboardPage() { } function selectAgentItem(item, actionKey) { + setCompanyActive(null); setActive("Inbox"); setLens("all"); setAgentNav(actionKey); @@ -190,6 +228,7 @@ export default function DashboardPage() { onComplete={() => { setCompanyDomain({ ...companyDomain, status: "active" }); setNeedsDnsSetup(false); + setCompanyActive("Overview"); }} onLogout={logout} /> @@ -200,6 +239,7 @@ export default function DashboardPage() { const workspaceTitle = account.account_type === "company" && companyDomain ? companyDomain.domain_name : "Personal workspace"; + const isDomainOwner = account.account_type === "company" && account.is_domain_owner; const mailActive = lens === "starred" ? "Starred" : active === "Outbox" @@ -236,6 +276,24 @@ export default function DashboardPage() { + {isDomainOwner && ( + + + + )} +