> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mailchannels.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Prepare customer DNS for an Outbound Filtering migration

> Audit customer domains in bulk, group them by nameserver and SPF status, and identify which DNS changes you can make and which need customer action.

Before you move customer email to MailChannels, check which sending domains authorize MailChannels in SPF. Group the results by authoritative nameserver so you can update DNS you manage and contact customers who manage DNS elsewhere.

For Outbound Filtering, the SPF authorization is `include:relay.mailchannels.net`. The audit below reads DNS and creates a CSV report. It does not change DNS records.

## Decide which domains need changes

Prioritize domains with `-all` that do not already authorize MailChannels. The `-` qualifier means **hard fail**: a receiving server can reject messages from an unauthorized sending IP. A hyphen inside a domain name, such as `include:spf-example.net`, does not indicate strict enforcement.

For a valid policy without a hard-fail mechanism, updating SPF is **optional for this migration**, but recommended to explicitly authorize MailChannels. This includes `~all` (soft fail), `?all` (neutral), and no SPF record. These results are distinct from SPF pass. See the [SPF result definitions](https://www.rfc-editor.org/rfc/rfc7208.html#section-2.6).

<Note>
  Optional does not guarantee delivery. If a domain relies on SPF for DMARC, it needs an SPF pass aligned with the visible From domain, or a passing, aligned DKIM signature. A soft-fail or neutral SPF result does not satisfy DMARC. Check [DMARC authentication and alignment](https://www.rfc-editor.org/rfc/rfc7489.html#section-3.1) before deferring an update.
</Note>

The report uses these statuses:

| SPF status        | Meaning                                                                                            | Your next step                                                                                                                               |
| ----------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `required`        | The policy uses `-all` and has no direct MailChannels include or other include to inspect.         | Authorize MailChannels before moving this domain's email. Verify any existing IP-based authorization before deciding an edit is unnecessary. |
| `optional`        | No SPF record, or a policy without hard-fail mechanisms and without a direct MailChannels include. | Recommend adding MailChannels; check DMARC before deferring.                                                                                 |
| `include_present` | A positive `include:relay.mailchannels.net` appears before any `all` mechanism.                    | Validate the complete policy and test delivery; the include alone is not proof of SPF pass.                                                  |
| `review`          | DNS failed, multiple SPF records exist, or the policy needs further inspection.                    | Resolve the issue before deciding whether an update is required or optional.                                                                 |

The script is an inventory tool, not a full SPF evaluator. It does not expand includes, evaluate sending IP addresses, or count recursive DNS lookups. A strict policy with another provider's include goes to `review`: that include might already authorize MailChannels. Policies with `redirect=`, macros, unusual qualifiers, or unrecognized syntax also go to `review`. An `all` or `+all` policy authorizes every sender and needs review.

## Prepare your domain and nameserver lists

Use a macOS, Linux, or Windows WSL terminal with Python 3.10 or later, `pip`, and virtual environment support. The commands below use Bash-compatible syntax and [dnspython](https://dnspython.readthedocs.io/en/stable/). Your machine must be able to query DNS through its configured resolver.

Create `domains.txt` with one sending domain per line. Replace these examples with your customer domains:

```bash theme={null}
cat > domains.txt <<'EOF'
example.com
example.net
mail.example.org
EOF
```

Include the domains used in the SMTP envelope sender, usually shown as the Return-Path on delivered messages. They can differ from the visible From address. Audit sending subdomains separately: SPF does not inherit from the parent domain. You can also include your SMTP HELO domain for messages with an empty envelope sender.

If you forward customer mail, include the envelope domains your MTA uses for SRS rewriting. Those domains must authorize MailChannels for the forwarded hop. See [Configure SRS for forwarded mail](/outbound/migration-center/test-and-roll-out#configure-srs-for-forwarded-mail).

Create `provider-nameservers.txt` with the exact names of **all nameservers for zones you manage**:

```bash theme={null}
cat > provider-nameservers.txt <<'EOF'
ns1.hosting-provider.example
ns2.hosting-provider.example
EOF
```

The script compares whole names, ignoring case and trailing dots. It labels a domain `provider` only when every nameserver is in your list. Partial matches become `review`; no matches become `customer`. Leave this file empty if you only want grouping without identifying your nameservers.

Nameservers identify DNS hosting, not account ownership. Confirm that you can edit each zone before treating it as provider-managed. Shared services such as Cloudflare can host both your accounts and your customers' accounts on the same nameservers.

## Run the bulk audit

Create an isolated Python environment and install the DNS library:

```bash theme={null}
python3 -m venv .spf-audit-venv
. .spf-audit-venv/bin/activate
python3 -m pip install 'dnspython>=2.6,<3'
```

Copy this entire block into your terminal to create `spf-audit.py`:

```bash theme={null}
cat > spf-audit.py <<'PY'
import csv
import ipaddress
from pathlib import Path
import re
import sys

import dns.exception
import dns.name
import dns.resolver


def read_names(path):
    names = set()
    for line in Path(path).read_text(encoding="utf-8-sig").splitlines():
        name = line.split("#", 1)[0].strip().rstrip(".")
        if not name:
            continue
        name = name.encode("idna").decode("ascii").lower()
        if len(name) > 253 or any(
            not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label)
            for label in name.split(".")
        ):
            raise ValueError(f"Invalid domain in {path}: {name}")
        names.add(name)
    return sorted(names)


def classify(records):
    if not records:
        return "optional", "No SPF record"
    if len(records) != 1:
        return "review", "Multiple SPF records"
    if any(c.isspace() and c != " " for c in records[0]):
        return "review", "Invalid SPF whitespace"
    terms = records[0].lower().split()[1:]
    # Recognize common literal syntax; send complex policies for review.
    host = r"[a-z0-9_](?:[a-z0-9_.-]*[a-z0-9])?\.?"
    for term in terms:
        body = term[1:] if term[:1] in "+-~?" else term
        if body.startswith(("ip4:", "ip6:")):
            try:
                network = ipaddress.ip_network(body[4:], strict=False)
                if network.version != int(body[2]):
                    raise ValueError("IP version mismatch")
            except ValueError:
                return "review", "Invalid IP mechanism"
        elif not re.fullmatch(rf"(?:all|a|mx|include:{host})", body):
            return "review", "Redirect, macro, or syntax requiring review"
    all_terms = [t for t in terms if t.lstrip("+-~?") == "all"]
    if len(all_terms) > 1:
        return "review", "Multiple all mechanisms"
    end = terms.index(all_terms[0]) if all_terms else len(terms)
    active = terms[:end]
    if any(t.startswith(("-", "~", "?")) for t in active):
        return "review", "Qualified mechanism needs evaluation"
    if all_terms and all_terms[0] in ("all", "+all"):
        return "review", "Policy authorizes every sender"
    includes = [t.lstrip("+").rstrip(".") for t in active]
    if "include:relay.mailchannels.net" in includes:
        return "include_present", "Direct include found; validate full SPF"
    if all_terms == ["-all"]:
        if any(t.startswith("include:") for t in includes):
            return "review", "Strict policy; check indirect authorization"
        return "required", "Strict policy without MailChannels include"
    return "optional", "No hard-fail mechanism; adding MailChannels recommended"


def audit(domain, managed, resolver):
    row = dict(domain=domain, dns_zone="", nameservers="", dns_owner="review",
               spf_status="review", spf_record="", notes="")
    notes = []
    try:
        zone = dns.resolver.zone_for_name(domain + ".", resolver=resolver,
                                          lifetime=10)
        if zone == dns.name.root:
            raise ValueError("No customer DNS zone found")
        row["dns_zone"] = zone.to_text().rstrip(".")
        answer = resolver.resolve(zone, "NS", lifetime=10, search=False)
        nameservers = {r.target.to_text().lower().rstrip(".") for r in answer}
        row["nameservers"] = ";".join(sorted(nameservers))
        if nameservers and nameservers <= managed:
            row["dns_owner"] = "provider"
        elif nameservers and not nameservers & managed:
            row["dns_owner"] = "customer"
        else:
            notes.append("Mixed or missing nameservers; confirm zone access")
    except (dns.exception.DNSException, ValueError) as error:
        reason = str(error) if isinstance(error, ValueError) else type(error).__name__
        notes.append("NS lookup: " + reason)
    try:
        # NoAnswer means the name exists but has no TXT records.
        # NXDOMAIN, SERVFAIL, and timeouts must remain review items.
        answer = resolver.resolve(domain + ".", "TXT", lifetime=10,
                                  search=False, raise_on_no_answer=False)
        records = [b"".join(r.strings).decode("ascii") for r in answer]
        spf = [r for r in records if re.match(r"^v=spf1(?:\s|$)", r, re.I)]
        row["spf_record"] = " | ".join(spf)
        if answer.canonical_name != dns.name.from_text(domain + "."):
            notes.append("TXT follows a DNS alias; review the target zone")
        else:
            row["spf_status"], reason = classify(spf)
            notes.append(reason)
    except (dns.exception.DNSException, UnicodeError) as error:
        notes.append("TXT lookup: " + type(error).__name__)
    row["notes"] = "; ".join(notes)
    return row


def main():
    if len(sys.argv) != 3:
        raise SystemExit("Usage: python3 spf-audit.py domains.txt provider-nameservers.txt")
    domains = read_names(sys.argv[1])
    managed = set(read_names(sys.argv[2]))
    resolver = dns.resolver.Resolver()
    resolver.cache = dns.resolver.Cache()
    rows = []
    for index, domain in enumerate(domains, 1):
        print(f"[{index}/{len(domains)}] {domain}", file=sys.stderr)
        rows.append(audit(domain, managed, resolver))
    rows.sort(key=lambda r: (r["nameservers"], r["spf_status"], r["domain"]))
    fields = ["nameservers", "spf_status", "dns_owner", "domain",
              "dns_zone", "spf_record", "notes"]
    with open("spf-audit.csv", "w", newline="", encoding="utf-8") as output:
        writer = csv.DictWriter(output, fieldnames=fields)
        writer.writeheader()
        writer.writerows(rows)
    print(f"Wrote {len(rows)} domains to spf-audit.csv", file=sys.stderr)


if __name__ == "__main__":
    main()
PY
```

Run the audit:

```bash theme={null}
python3 spf-audit.py domains.txt provider-nameservers.txt
```

Open `spf-audit.csv` in a spreadsheet. The report sorts by the full nameserver set, then SPF status, then domain. It deduplicates input domains, joins split TXT strings, and finds the containing DNS zone for sending subdomains. Each DNS operation has a 10-second timeout; large lists or repeated DNS failures can take several minutes. Re-running the audit replaces the report.

Example rows, with the record and notes columns omitted:

| nameservers                                               | spf\_status | dns\_owner | domain           | dns\_zone   |
| --------------------------------------------------------- | ----------- | ---------- | ---------------- | ----------- |
| ns1.customer-dns.example;ns2.customer-dns.example         | required    | customer   | example.net      | example.net |
| ns1.hosting-provider.example;ns2.hosting-provider.example | optional    | provider   | mail.example.org | example.org |
| ns1.hosting-provider.example;ns2.hosting-provider.example | required    | provider   | example.com      | example.com |

## Apply changes by DNS owner

### Domains you manage

Filter for `dns_owner=provider` and prioritize `spf_status=required`. Resolve `review` items before cutover. In your authoritative DNS system, edit each domain's existing SPF TXT record to add `include:relay.mailchannels.net` before `all`. Keep existing senders and the existing `all` qualifier.

```text Before theme={null}
v=spf1 ip4:192.0.2.10 -all
```

```text After theme={null}
v=spf1 ip4:192.0.2.10 include:relay.mailchannels.net -all
```

Publish only one SPF record at each name. Keep the full policy within SPF's [10 DNS-querying-term limit](https://www.rfc-editor.org/rfc/rfc7208.html#section-4.6.4), including nested includes. Follow [SPF setup](/outbound/spf-records) and configure [Domain Lockdown™](/outbound/domain-lockdown) for your MailChannels account.

If customers already include an SPF record you control, inspect that shared record. Adding MailChannels there may cover those customers without individual DNS edits. Verify the include chain, mechanism order, and lookup budget before relying on this approach.

### Domains managed by customers

Filter for `dns_owner=customer`. Confirm who controls DNS, then send required changes before moving that domain's mail. Give customers the exact record name and their complete proposed SPF value, preserving existing senders.

Adapt this message for each customer:

```text theme={null}
We are moving your outbound email to MailChannels.

Your sending domain: [domain]
Your DNS nameservers: [nameservers]
SPF update: [required before migration / optional but recommended]

In your DNS provider's control panel, edit the existing TXT record:
Record name: [sending domain or the provider's equivalent host field]
Current value: [current SPF record]
New value: [complete SPF record with include:relay.mailchannels.net before all]

Keep one SPF record at this name. Do not remove other sending services.
If you have no SPF record, create the TXT record with the value we supply.

Also configure the Domain Lockdown TXT record we provide:
Record name: _mailchannels.[domain]
Value: v=mc1 auth=[our MailChannels account ID]
If a Domain Lockdown record already exists, contact us to confirm the
combined value so existing authorized accounts remain permitted.

Please confirm when you have saved these changes so we can verify DNS
and test delivery before moving your email.
```

For `optional` domains, explain that the existing policy does not impose an SPF hard fail, but adding MailChannels improves explicit authorization. Confirm aligned DKIM works if the customer will rely on it for DMARC.

## Verify before moving traffic

Allow the previous DNS TTL to expire, then run the audit again. Check `review` rows and confirm expected includes appear. For indirect authorization, validate the referenced policy separately because this audit will still flag it for review.

Send real messages from a domain you control, using a workload with little impact if delivery fails, such as noncritical cron reports or internal notifications. Repeated messages such as "Testing 1 2 3" are likely to be classified as spam and blocked. Inspect the receiving server's `Authentication-Results` header for SPF and DMARC results. Test domains in each DNS and SPF group before expanding the rollout. Continue with [mail server configuration](/outbound/configure-mail-server) and use [Log Search](/outbound/log-search) to investigate delivery issues.

For a complete test plan, pilot stages, and rollback criteria, see [Testing and phased rollout](/outbound/migration-center/test-and-roll-out).
