domain migration
This commit is contained in:
parent
192b810eb4
commit
c04a6ce6fe
26 changed files with 777 additions and 125 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:<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}."
|
||||
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),
|
||||
|
|
|
|||
49
backend/app/services/domain_policy.py
Normal file
49
backend/app/services/domain_policy.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Company domain registration policy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.domain_provisioning import normalize_domain, validate_domain
|
||||
|
||||
# Well-known domains that must never be used for company onboarding.
|
||||
BLOCKED_COMPANY_DOMAINS = frozenset(
|
||||
{
|
||||
"google.com",
|
||||
"gmail.com",
|
||||
"yahoo.com",
|
||||
"outlook.com",
|
||||
"hotmail.com",
|
||||
"live.com",
|
||||
"icloud.com",
|
||||
"apple.com",
|
||||
"microsoft.com",
|
||||
"amazon.com",
|
||||
"facebook.com",
|
||||
"meta.com",
|
||||
"linkedin.com",
|
||||
"proton.me",
|
||||
"protonmail.com",
|
||||
"aol.com",
|
||||
"zoho.com",
|
||||
"fastmail.com",
|
||||
"yandex.com",
|
||||
"mail.ru",
|
||||
"cloudflare.com",
|
||||
"github.com",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def assert_company_domain_allowed(domain: str, platform_domain: str) -> str:
|
||||
"""Validate format and reject reserved or platform domains."""
|
||||
normalized = validate_domain(domain)
|
||||
if normalized == normalize_domain(platform_domain):
|
||||
raise ValueError(
|
||||
f"You cannot register {platform_domain} as a company domain. "
|
||||
"Use a domain your organization controls."
|
||||
)
|
||||
if normalized in BLOCKED_COMPANY_DOMAINS:
|
||||
raise ValueError(
|
||||
"That domain cannot be used for company signup. "
|
||||
"Enter a domain you control and can edit in DNS."
|
||||
)
|
||||
return normalized
|
||||
|
|
@ -94,11 +94,21 @@ def build_company_dns_records(
|
|||
dkim_selector: str,
|
||||
dkim_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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from app.services.raw_mime_storage import StoredMime
|
|||
|
||||
RAW = (
|
||||
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"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
|
||||
|
|
|
|||
|
|
@ -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 <user@getall.help>\r\n"
|
||||
b"To: User <user@gentic.email>\r\n"
|
||||
b"Subject: Production test\r\n"
|
||||
b"Message-ID: <one@example.com>\r\n"
|
||||
b"MIME-Version: 1.0\r\n"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue