{"id":1755,"date":"2025-11-12T20:15:54","date_gmt":"2025-11-12T17:15:54","guid":{"rendered":"https:\/\/www.dchost.com\/blog\/beat-the-10-lookup-wall-automated-spf-flattening-with-ci-cd-or-workers-without-the-drama\/"},"modified":"2025-11-12T20:15:54","modified_gmt":"2025-11-12T17:15:54","slug":"beat-the-10-lookup-wall-automated-spf-flattening-with-ci-cd-or-workers-without-the-drama","status":"publish","type":"post","link":"https:\/\/www.dchost.com\/blog\/en\/beat-the-10-lookup-wall-automated-spf-flattening-with-ci-cd-or-workers-without-the-drama\/","title":{"rendered":"Beat the 10 Lookup Wall: Automated SPF Flattening with CI\/CD or Workers (Without the Drama)"},"content":{"rendered":"<div class=\"dchost-blog-content-wrapper\"><p>So there I was, late on a Thursday, staring at a perfectly innocent TXT record that had somehow turned our client\u2019s emails into ghosts. No bounces. No delivery. Just silence. Their SPF record looked clean, but buried inside were more \u201cinclude\u201ds than a family tree. And that\u2019s when it hit me\u2014yep, we\u2019d smacked into the SPF 10 DNS lookup limit. Again. If you\u2019ve ever watched a marketing blast vanish into the ether, you know the feeling. The fix? Flattening. But here\u2019s the thing: doing it manually is like mowing a lawn with scissors. The grass grows back\u2014and often when you\u2019re off for the weekend. That\u2019s why I started automating SPF flattening with CI\/CD and, later, with Workers. It kept us clear of the limit, avoided stale IPs, and most importantly, let the team sleep.<\/p>\n<p>In this guide, I\u2019ll walk you through what the 10 lookup limit actually means, why flattening helps (and when it can hurt), and two practical automation paths\u2014CI\/CD pipelines or scheduled Workers\u2014that keep your SPF fast, tidy, and self-healing. I\u2019ll share the little gotchas, how to handle providers like Google Workspace, Microsoft 365, Mailgun, and SendGrid, and how to test changes safely. The goal isn\u2019t to \u201chack around\u201d SPF. It\u2019s to respect it\u2014and make it work for you without a weekly fire drill.<\/p>\n<div id=\"toc_container\" class=\"toc_transparent no_bullets\"><p class=\"toc_title\">\u0130&ccedil;indekiler<\/p><ul class=\"toc_list\"><li><a href=\"#The_SPF_10_Lookup_Limit_Explained_Like_a_Coffee_Chat\"><span class=\"toc_number toc_depth_1\">1<\/span> The SPF 10 Lookup Limit, Explained Like a Coffee Chat<\/a><\/li><li><a href=\"#Why_Flattening_Helps_and_Why_Manual_Flattening_Can_Bite\"><span class=\"toc_number toc_depth_1\">2<\/span> Why Flattening Helps (and Why Manual Flattening Can Bite)<\/a><\/li><li><a href=\"#Path_One_Automated_SPF_Flattening_with_CICD\"><span class=\"toc_number toc_depth_1\">3<\/span> Path One: Automated SPF Flattening with CI\/CD<\/a><ul><li><a href=\"#How_I_Structure_the_Repo\"><span class=\"toc_number toc_depth_2\">3.1<\/span> How I Structure the Repo<\/a><\/li><li><a href=\"#A_Small_Python_Script_That_Gets_It_Done\"><span class=\"toc_number toc_depth_2\">3.2<\/span> A Small Python Script That Gets It Done<\/a><\/li><li><a href=\"#GitHub_Actions_A_Calm_Scheduled_Workflow\"><span class=\"toc_number toc_depth_2\">3.3<\/span> GitHub Actions: A Calm Scheduled Workflow<\/a><\/li><\/ul><\/li><li><a href=\"#Path_Two_Scheduled_Workers_That_Keep_SPF_Fresh_on_Their_Own\"><span class=\"toc_number toc_depth_1\">4<\/span> Path Two: Scheduled Workers That Keep SPF Fresh on Their Own<\/a><ul><li><a href=\"#A_Lightweight_Worker_With_a_Cron_Trigger\"><span class=\"toc_number toc_depth_2\">4.1<\/span> A Lightweight Worker With a Cron Trigger<\/a><\/li><\/ul><\/li><li><a href=\"#A_Calm_Strategy_That_Holds_Up_Over_Time\"><span class=\"toc_number toc_depth_1\">5<\/span> A Calm Strategy That Holds Up Over Time<\/a><\/li><li><a href=\"#Testing_Guardrails_and_Real-World_Gotchas\"><span class=\"toc_number toc_depth_1\">6<\/span> Testing, Guardrails, and Real-World Gotchas<\/a><\/li><li><a href=\"#CICD_vs_Workers_Pick_What_Fits_Your_Brain\"><span class=\"toc_number toc_depth_1\">7<\/span> CI\/CD vs Workers: Pick What Fits Your Brain<\/a><\/li><li><a href=\"#A_Quick_Word_on_Security_and_Secrets\"><span class=\"toc_number toc_depth_1\">8<\/span> A Quick Word on Security and Secrets<\/a><\/li><li><a href=\"#When_Providers_Publish_Complex_SPF\"><span class=\"toc_number toc_depth_1\">9<\/span> When Providers Publish Complex SPF<\/a><\/li><li><a href=\"#What_About_Forwarding_SRS_and_Alignment\"><span class=\"toc_number toc_depth_1\">10<\/span> What About Forwarding, SRS, and Alignment?<\/a><\/li><li><a href=\"#Practical_Walkthrough_A_Small_Safe_Rollout_Plan\"><span class=\"toc_number toc_depth_1\">11<\/span> Practical Walkthrough: A Small, Safe Rollout Plan<\/a><\/li><li><a href=\"#A_Note_on_Documentation_and_Team_Habits\"><span class=\"toc_number toc_depth_1\">12<\/span> A Note on Documentation and Team Habits<\/a><\/li><li><a href=\"#Related_Homework_if_Youre_in_the_Mood\"><span class=\"toc_number toc_depth_1\">13<\/span> Related Homework if You\u2019re in the Mood<\/a><\/li><li><a href=\"#Wrap-Up_The_Calm_Path_Past_the_10_Lookup_Limit\"><span class=\"toc_number toc_depth_1\">14<\/span> Wrap-Up: The Calm Path Past the 10 Lookup Limit<\/a><ul><li><a href=\"#Helpful_References_You_Can_Keep_Open\"><span class=\"toc_number toc_depth_2\">14.1<\/span> Helpful References You Can Keep Open<\/a><\/li><\/ul><\/li><\/ul><\/div>\n<h2 id=\"section-1\"><span id=\"The_SPF_10_Lookup_Limit_Explained_Like_a_Coffee_Chat\">The SPF 10 Lookup Limit, Explained Like a Coffee Chat<\/span><\/h2>\n<p>Let\u2019s demystify the wall you keep hitting. SPF (Sender Policy Framework) is a DNS-based way to say \u201cthese IPs are allowed to send mail for my domain.\u201d The record lives in DNS (as a TXT record) and can include mechanisms like <strong>ip4<\/strong>\/<strong>ip6<\/strong>, <strong>a<\/strong>, <strong>mx<\/strong>, <strong>include<\/strong>, and a few others. The catch? Some of those mechanisms trigger DNS lookups during the receiver\u2019s SPF evaluation. And SPF puts a hard cap on that. Ten. Not eleven. Ten total DNS-querying mechanisms and modifiers per evaluation.<\/p>\n<p>Which ones count? <em>include<\/em> definitely. <em>a<\/em> and <em>mx<\/em> do too because they resolve to IPs. <em>exists<\/em> and <em>ptr<\/em> also trigger lookups (and <em>ptr<\/em> should really be avoided in modern setups). The <em>redirect<\/em> modifier counts as well, because it points evaluation to another domain. What doesn\u2019t count? <em>ip4<\/em> and <em>ip6<\/em> entries\u2014these are literal addresses and cost zero lookups. The spec has a few more nuances, like the idea of \u201cvoid lookups,\u201d but here\u2019s the plain version: once the receiving server hits that tenth query while checking your SPF, many implementations mark your record as a \u201cPermError,\u201d and that can snowball into delivery issues. If you want the official bedtime reading, <a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc7208\" rel=\"nofollow noopener\" target=\"_blank\">the SPF RFC 7208<\/a> lays it all out.<\/p>\n<p>The real-world problem is modern email stacks. One business domain can touch three or four senders easily: Google or Microsoft for regular mail, a marketing platform, a transactional provider, and maybe a backup relay for edge cases. Each one publishes its own SPF via <em>include<\/em>, and\u2014surprise\u2014those includes pull in more includes. Ten comes fast.<\/p>\n<h2 id=\"section-2\"><span id=\"Why_Flattening_Helps_and_Why_Manual_Flattening_Can_Bite\">Why Flattening Helps (and Why Manual Flattening Can Bite)<\/span><\/h2>\n<p>Flattening means converting all those indirect lookups into direct IP ranges. Instead of saying \u201cinclude:_spf.provider.com,\u201d your SPF says \u201cip4:1.2.3.4 ip4:5.6.7.0\/24 \u2026\u201d and so on. Receivers don\u2019t need to make DNS detours; they just match an IP. Zero lookups there, which means you can dodge the 10-lookup wall and keep your SPF evaluation snappy.<\/p>\n<p>There\u2019s a catch though, and I learned it the hard way. One of my clients ran a clean marketing calendar. Every Tuesday, a big newsletter. One day, they flattened manually, pasted in the IPs from their providers, and moved on. Weeks later, the provider rotated IP ranges (not unusual), and half the newsletter went to spam or disappeared. Why? The flattened SPF was stale. The include would have kept up, but the hard-coded IPs did not.<\/p>\n<p>That\u2019s the life lesson. Flattening is great only if it\u2019s <strong>kept fresh automatically<\/strong>. Providers change backends. Your stack evolves. You don\u2019t want to be the person who has \u201cupdate SPF IPs\u201d as a recurring Friday task. Automate it or skip flattening entirely. And if you skip it, you\u2019ll probably fight the 10-lookup limit again soon enough.<\/p>\n<h2 id=\"section-3\"><span id=\"Path_One_Automated_SPF_Flattening_with_CICD\">Path One: Automated SPF Flattening with CI\/CD<\/span><\/h2>\n<p>In my experience, the CI\/CD route feels natural if you\u2019re already using GitHub, GitLab, or similar. The idea is simple. You keep a tiny configuration file that defines which include domains or senders your domain relies on. A small script resolves those includes, recursively collects the IPs, and writes out a flattened SPF string. A scheduled pipeline then updates your DNS via an API. Nobody touches it day-to-day, and you get a clean commit history for every change.<\/p>\n<h3><span id=\"How_I_Structure_the_Repo\">How I Structure the Repo<\/span><\/h3>\n<p>I usually keep it minimal. A <strong>spf.yml<\/strong> that lists your senders or includes, a <strong>flatten_spf.py<\/strong> script, and a CI workflow file. The config can be incredibly simple\u2014just your domain and the include domains you trust. The script does the heavy lifting: resolve TXT, follow \u201cinclude\u201d and \u201credirect,\u201d expand \u201ca\u201d and \u201cmx\u201d to IPs, and ignore the problematic stuff like \u201cptr.\u201d Then it outputs a single SPF record with ip4\/ip6 ranges. Finally, the workflow updates your DNS record using your provider\u2019s API token.<\/p>\n<h3><span id=\"A_Small_Python_Script_That_Gets_It_Done\">A Small Python Script That Gets It Done<\/span><\/h3>\n<p>Here\u2019s a simplified script that has served me well for flattening. It uses dnspython and tries to be polite about recursion. It won\u2019t be perfect for every edge case, but it\u2019s a solid starting point:<\/p>\n<pre class=\"language-python line-numbers\"><code class=\"language-python\">#!\/usr\/bin\/env python3\nimport sys\nimport re\nimport json\nfrom collections import deque\n\ntry:\n    import dns.resolver\n    import dns.exception\nexcept ImportError:\n    print(&quot;Please pip install dnspython&quot;, file=sys.stderr)\n    sys.exit(1)\n\nRE_SPF = re.compile(r's+')\n\ndef fetch_txt(name):\n    try:\n        answers = dns.resolver.resolve(name, 'TXT')\n    except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers, dns.exception.DNSException):\n        return []\n    out = []\n    for rdata in answers:\n        # Join multi-string TXT chunks\n        txt = ''.join([part.decode('utf-8') if isinstance(part, bytes) else part for part in rdata.strings])\n        out.append(txt)\n    return out\n\n\ndef parse_spf(txt):\n    # returns list of tokens like ['v=spf1', 'ip4:1.2.3.4', 'include:_spf.example.com', ...]\n    return RE_SPF.split(txt.strip())\n\n\ndef is_spf(txt):\n    return txt.lower().startswith('v=spf1')\n\n\ndef resolve_a(domain):\n    ips = set()\n    for rrtype in ['A', 'AAAA']:\n        try:\n            answers = dns.resolver.resolve(domain, rrtype)\n            for r in answers:\n                ips.add(r.address)\n        except Exception:\n            pass\n    return ips\n\n\ndef resolve_mx(domain):\n    mx_hosts = []\n    try:\n        answers = dns.resolver.resolve(domain, 'MX')\n        for r in answers:\n            mx_hosts.append(str(r.exchange).rstrip('.'))\n    except Exception:\n        return set()\n    ips = set()\n    for host in mx_hosts:\n        ips |= resolve_a(host)\n    return ips\n\n\ndef flatten(domain):\n    seen_domains = set()\n    ip4s, ip6s = set(), set()\n    queue = deque([domain])\n\n    while queue:\n        d = queue.popleft()\n        if d in seen_domains:\n            continue\n        seen_domains.add(d)\n\n        txts = fetch_txt(d)\n        spf_records = [t for t in txts if is_spf(t)]\n        if not spf_records:\n            continue\n\n        # Use the first SPF record found\n        tokens = parse_spf(spf_records[0])\n        for tok in tokens:\n            t = tok.lower()\n            if t.startswith('ip4:'):\n                ip4s.add(tok.split(':', 1)[1])\n            elif t.startswith('ip6:'):\n                ip6s.add(tok.split(':', 1)[1])\n            elif t.startswith('include:'):\n                inc = tok.split(':', 1)[1]\n                queue.append(inc)\n            elif t.startswith('redirect='):\n                red = tok.split('=', 1)[1]\n                queue.append(red)\n            elif t == 'a' or t.startswith('a:'):\n                target = tok.split(':', 1)[1] if ':' in tok else d\n                ips = resolve_a(target)\n                for ip in ips:\n                    if ':' in ip:\n                        ip6s.add(ip)\n                    else:\n                        ip4s.add(ip)\n            elif t == 'mx' or t.startswith('mx:'):\n                target = tok.split(':', 1)[1] if ':' in tok else d\n                ips = resolve_mx(target)\n                for ip in ips:\n                    if ':' in ip:\n                        ip6s.add(ip)\n                    else:\n                        ip4s.add(ip)\n            # We deliberately skip: ptr, exists, exp, and modifiers we don't want to expand\n\n    return sorted(ip4s), sorted(ip6s)\n\n\ndef build_spf(ip4s, ip6s, policy='-all'):\n    parts = ['v=spf1']\n    parts += [f'ip4:{x}' for x in ip4s]\n    parts += [f'ip6:{x}' for x in ip6s]\n    parts.append(policy)\n    spf = ' '.join(parts)\n    # Split into &lt;=255 byte chunks for TXT\n    chunks = []\n    cur = ''\n    for ch in spf:\n        if len(cur.encode('utf-8')) + len(ch.encode('utf-8')) &gt;= 255:\n            chunks.append(cur)\n            cur = ch\n        else:\n            cur += ch\n    if cur:\n        chunks.append(cur)\n    return chunks\n\nif __name__ == '__main__':\n    if len(sys.argv) &lt; 2:\n        print('Usage: flatten_spf.py DOMAIN', file=sys.stderr)\n        sys.exit(2)\n    domain = sys.argv[1]\n    ip4s, ip6s = flatten(domain)\n    chunks = build_spf(ip4s, ip6s)\n    result = {\n        'domain': domain,\n        'ip4': ip4s,\n        'ip6': ip6s,\n        'txt_chunks': chunks\n    }\n    print(json.dumps(result, indent=2))\n<\/code><\/pre>\n<p>This script takes a domain that already publishes an SPF, resolves its includes, expands a\/mx where possible, collects IPs, and emits a flattened SPF string split into 255-byte chunks. In the real world, I sometimes invert the flow: keep a list of known include domains (like \u201c_spf.google.com\u201d or \u201cspf.protection.outlook.com\u201d) and flatten those directly into a dedicated policy domain like \u201c_spf.example.com.\u201d Then, in my root policy, I use a tiny delegator like \u201cv=spf1 redirect=_spf.example.com.\u201d That way, I can keep the public-facing record tidy while the automation updates the backend record.<\/p>\n<h3><span id=\"GitHub_Actions_A_Calm_Scheduled_Workflow\">GitHub Actions: A Calm Scheduled Workflow<\/span><\/h3>\n<p>Here\u2019s a compact GitHub Actions workflow that runs on a schedule, flattens, and updates the DNS record using the Cloudflare API. Adapt it for your provider if you\u2019re on Route 53, Google Cloud DNS, or elsewhere. The idea is the same\u2014build the string, push via API, and forget it until it saves your bacon.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">name: Flatten SPF\n\non:\n  schedule:\n    - cron: '0 *\/6 * * *'  # every 6 hours\n  workflow_dispatch: {}\n\njobs:\n  build-and-publish:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions\/checkout@v4\n\n      - name: Setup Python\n        uses: actions\/setup-python@v5\n        with:\n          python-version: '3.11'\n\n      - name: Install deps\n        run: pip install dnspython\n\n      - name: Flatten SPF\n        id: flatten\n        run: |\n          python flatten_spf.py _spf.example.com &gt; out.json\n          echo &quot;RESULT=&lt;$(cat out.json)&gt;&quot; &gt;&gt; $GITHUB_OUTPUT\n\n      - name: Update Cloudflare TXT\n        env:\n          CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}\n          CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}\n          RECORD_ID: ${{ secrets.SPF_RECORD_ID }}\n        run: |\n          JSON=$(cat out.json)\n          TXT=$(echo &quot;$JSON&quot; | jq -c '.txt_chunks')\n          NAME=&quot;_spf.example.com&quot;\n          # Cloudflare expects strings array for TXT chunks\n          DATA=$(jq -n --arg name &quot;$NAME&quot; --argjson content $TXT '{type:&quot;TXT&quot;, name:$name, content:$content}')\n          curl -sS -X PUT \n            -H &quot;Authorization: Bearer $CF_API_TOKEN&quot; \n            -H &quot;Content-Type: application\/json&quot; \n            &quot;https:\/\/api.cloudflare.com\/client\/v4\/zones\/$CF_ZONE_ID\/dns_records\/$RECORD_ID&quot; \n            --data &quot;$DATA&quot;\n<\/code><\/pre>\n<p>That\u2019s the core. A few field notes: I like running it every 6 or 12 hours. You could go daily, but I\u2019ve seen providers change IPs mid-day. Use secrets for API tokens and record IDs. And test on a staging domain first\u2014point a subdomain like \u201cspf-staging.example.com,\u201d verify the TXT, then switch your redirect to it once you\u2019re happy.<\/p>\n<p>If you need a refresher on the broader email authentication picture, I\u2019ve written a friendly deep dive on DMARC that pairs beautifully with this topic: <a href=\"https:\/\/www.dchost.com\/blog\/en\/gelismis-dmarc-ve-bimi-rua-ruf-raporlarindan-marka-gostergesine-nasil-yol-alinir\/\">Beyond p=none: a friendly playbook for advanced DMARC, RUA\/RUF analysis, and BIMI<\/a>. It helps you see how SPF fits into the whole deliverability story.<\/p>\n<h2 id=\"section-4\"><span id=\"Path_Two_Scheduled_Workers_That_Keep_SPF_Fresh_on_Their_Own\">Path Two: Scheduled Workers That Keep SPF Fresh on Their Own<\/span><\/h2>\n<p>Maybe you\u2019d rather keep it all inside your DNS provider\u2019s ecosystem. That\u2019s where scheduled Workers shine. With something like <a href=\"https:\/\/developers.cloudflare.com\/workers\/\" rel=\"nofollow noopener\" target=\"_blank\">Cloudflare Workers and scheduled Cron triggers<\/a>, you can write a tiny script that wakes up every few hours, rebuilds your flattened SPF from the canonical includes, and updates the DNS record via API. No CI server needed.<\/p>\n<p>The architecture is the same at heart: resolve, flatten, publish. The difference is who\u2019s driving. Workers are good for \u201cset and forget,\u201d and you can keep a small list of include domains in environment variables or KV storage. Some teams like that because it reduces moving parts; others prefer CI because they love the audit trail of commits. Either way works.<\/p>\n<h3><span id=\"A_Lightweight_Worker_With_a_Cron_Trigger\">A Lightweight Worker With a Cron Trigger<\/span><\/h3>\n<p>Here\u2019s a small example using the module syntax and a scheduled handler. Real-world code might use KV to store your include list and chunking logic shared across runs. But this shows the idea clearly:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">export default {\n  async scheduled(event, env, ctx) {\n    const includes = (env.INCLUDES || '_spf.google.com, spf.protection.outlook.com').split(',').map(s =&gt; s.trim())\n    const name = env.SPF_RECORD_NAME || '_spf.example.com'\n    const zoneId = env.CF_ZONE_ID\n    const apiToken = env.CF_API_TOKEN\n\n    const { ip4, ip6 } = await flattenIncludes(includes)\n    const spf = buildSpf(ip4, ip6)\n\n    const ok = await updateTxtRecord(zoneId, apiToken, name, spf)\n    if (!ok) throw new Error('Failed to update SPF record')\n  }\n}\n\nasync function dnsTxt(name) {\n  \/\/ Use DNS over HTTPS for TXT lookups\n  const url = `https:\/\/cloudflare-dns.com\/dns-query?name=${encodeURIComponent(name)}&amp;type=TXT`\n  const res = await fetch(url, { headers: { 'accept': 'application\/dns-json' } })\n  if (!res.ok) return []\n  const data = await res.json()\n  const answers = data.Answer || []\n  return answers\n    .filter(a =&gt; a.type === 16)\n    .map(a =&gt; a.data.replace(\/^&quot;|&quot;$\/g, '')) \/\/ basic unquote\n}\n\nfunction parseSpf(txt) {\n  return txt.trim().split(\/s+\/)\n}\n\nasync function resolveA(name) {\n  const ips = new Set()\n  for (const type of ['A', 'AAAA']) {\n    const url = `https:\/\/cloudflare-dns.com\/dns-query?name=${encodeURIComponent(name)}&amp;type=${type}`\n    const res = await fetch(url, { headers: { 'accept': 'application\/dns-json' } })\n    if (!res.ok) continue\n    const data = await res.json()\n    const answers = data.Answer || []\n    for (const a of answers) {\n      if (type === 'A') ips.add(a.data)\n      if (type === 'AAAA') ips.add(a.data)\n    }\n  }\n  return [...ips]\n}\n\nasync function resolveMx(name) {\n  const url = `https:\/\/cloudflare-dns.com\/dns-query?name=${encodeURIComponent(name)}&amp;type=MX`\n  const res = await fetch(url, { headers: { 'accept': 'application\/dns-json' } })\n  if (!res.ok) return []\n  const data = await res.json()\n  const answers = data.Answer || []\n  const hosts = answers.map(a =&gt; a.data.split(' ').slice(1).join(' ').replace(\/.$\/, ''))\n  const ips = new Set()\n  for (const h of hosts) {\n    const arr = await resolveA(h)\n    for (const ip of arr) ips.add(ip)\n  }\n  return [...ips]\n}\n\nasync function flattenIncludes(includeDomains) {\n  const seen = new Set()\n  const ip4 = new Set()\n  const ip6 = new Set()\n  const queue = [...includeDomains]\n\n  while (queue.length) {\n    const d = queue.shift()\n    if (seen.has(d)) continue\n    seen.add(d)\n\n    const txts = await dnsTxt(d)\n    const spf = txts.find(t =&gt; t.toLowerCase().startsWith('v=spf1'))\n    if (!spf) continue\n    for (const tok of parseSpf(spf)) {\n      const t = tok.toLowerCase()\n      if (t.startsWith('ip4:')) ip4.add(tok.split(':', 1)[1])\n      else if (t.startsWith('ip6:')) ip6.add(tok.split(':', 1)[1])\n      else if (t.startsWith('include:')) queue.push(tok.split(':', 1)[1])\n      else if (t.startsWith('redirect=')) queue.push(tok.split('=', 1)[1])\n      else if (t === 'a' || t.startsWith('a:')) {\n        const target = t.includes(':') ? tok.split(':', 1)[1] : d\n        const arr = await resolveA(target)\n        for (const ip of arr) (ip.includes(':') ? ip6 : ip4).add(ip)\n      } else if (t === 'mx' || t.startsWith('mx:')) {\n        const target = t.includes(':') ? tok.split(':', 1)[1] : d\n        const arr = await resolveMx(target)\n        for (const ip of arr) (ip.includes(':') ? ip6 : ip4).add(ip)\n      }\n    }\n  }\n  return { ip4: [...ip4].sort(), ip6: [...ip6].sort() }\n}\n\nfunction buildSpf(ip4, ip6, policy='-all') {\n  const parts = ['v=spf1']\n  for (const i of ip4) parts.push(`ip4:${i}`)\n  for (const i of ip6) parts.push(`ip6:${i}`)\n  parts.push(policy)\n  const spf = parts.join(' ')\n  \/\/ Return array of chunks under 255 bytes, as CF supports multi-string TXT\n  const out = []\n  let cur = ''\n  for (const ch of spf) {\n    if (new TextEncoder().encode(cur + ch).length &gt;= 255) { out.push(cur); cur = ch } else cur += ch\n  }\n  if (cur) out.push(cur)\n  return out\n}\n\nasync function updateTxtRecord(zoneId, apiToken, name, chunks) {\n  \/\/ You will want to discover RECORD_ID by listing records once, or store it in a secret.\n  const list = await fetch(`https:\/\/api.cloudflare.com\/client\/v4\/zones\/${zoneId}\/dns_records?type=TXT&amp;name=${encodeURIComponent(name)}`, {\n    headers: { 'Authorization': `Bearer ${apiToken}` }\n  })\n  if (!list.ok) return false\n  const info = await list.json()\n  const record = (info.result || [])[0]\n  const body = JSON.stringify({ type: 'TXT', name, content: chunks })\n  const url = record ? `https:\/\/api.cloudflare.com\/client\/v4\/zones\/${zoneId}\/dns_records\/${record.id}`\n                     : `https:\/\/api.cloudflare.com\/client\/v4\/zones\/${zoneId}\/dns_records`\n\n  const res = await fetch(url, {\n    method: record ? 'PUT' : 'POST',\n    headers: { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application\/json' },\n    body\n  })\n  return res.ok\n}\n<\/code><\/pre>\n<p>It\u2019s not production-perfect, but it demonstrates the loop: gather, flatten, chunk, publish. The Cron trigger keeps it on schedule. And if you ever need to rotate providers, you just update your env list and let the Worker do the next run.<\/p>\n<h2 id=\"section-5\"><span id=\"A_Calm_Strategy_That_Holds_Up_Over_Time\">A Calm Strategy That Holds Up Over Time<\/span><\/h2>\n<p>Flattening is not just about beating a number. It\u2019s about doing it safely so your marketing and transactional mail keeps flowing. A few patterns have saved me more than once:<\/p>\n<p>First, use a dedicated policy domain. I like something like \u201c_spf.example.com\u201d as the canonical flattened record, and then \u201cv=spf1 redirect=_spf.example.com\u201d on the root (or wherever you need the policy). It keeps your public record short, and swaps become a one-liner. Second, keep the policy strict. In stable environments, I default to \u201c-all\u201d rather than \u201c~all.\u201d If you\u2019re still mapping your sender landscape, start softer and move to \u201c-all\u201d once you\u2019re confident. Third, cap record size. TXT records must be split into 255-byte chunks at the DNS layer, and I try to keep the overall SPF under a few KB. If your flattened policy gets unwieldy, it\u2019s a hint you might be authorizing more infrastructure than you really use.<\/p>\n<p>Another trick is the \u201cemergency switch.\u201d Keep a backup redirect ready, like \u201c_spf_safe.example.com,\u201d that contains a minimal known-good set (your primary provider\u2019s ranges). If something goes wrong in production\u2014say a provider publishes a malformed SPF and your automation chokes\u2014you can swap the redirect and stabilize within minutes.<\/p>\n<p>Finally, make SPF one piece of a complete picture. SPF is about the envelope sender and connects nicely with DKIM and DMARC. If you haven\u2019t tuned DMARC reporting yet, it\u2019s worth your time; it\u2019s where you see the reality of which IPs are sending on your behalf. I\u2019ve walked through that in detail here: <a href=\"https:\/\/www.dchost.com\/blog\/en\/gelismis-dmarc-ve-bimi-rua-ruf-raporlarindan-marka-gostergesine-nasil-yol-alinir\/\">a friendly playbook for advanced DMARC, RUA\/RUF analysis, and BIMI<\/a>. That article pairs well with a flattened SPF because it helps you prune authorization over time.<\/p>\n<h2 id=\"section-6\"><span id=\"Testing_Guardrails_and_Real-World_Gotchas\">Testing, Guardrails, and Real-World Gotchas<\/span><\/h2>\n<p>Here\u2019s what I wish someone had told me on day one. Testing isn\u2019t just \u201cdig and done.\u201d When you publish a new flattened record, dig it from different recursive resolvers and confirm you see the chunked strings intact. I like running a quick manually rendered SPF evaluation against known senders\u2014just to confirm that the IPs we actually use are matched by the record. Then send a few test emails to mailboxes that show full headers and look for the \u201cReceived-SPF: pass\u201d line. That tiny check has saved too many late nights.<\/p>\n<p>Be mindful of \u201cvoid lookups.\u201d While we\u2019re flattening, we stop relying on runtime DNS lookups during SPF evaluation. But your flattening process still performs those lookups at build-time. If you pull in an include that resolves to nowhere or returns NXDOMAIN, your script should skip gracefully. I also recommend a sanity limit on recursion depth and the number of tokens processed\u2014just in case a provider publishes an SPF record that goes a bit wild. If your pipeline encounters weirdness, fall back to the last known-good record rather than publishing something broken.<\/p>\n<p>TTL matters less than you think once you automate, but it still matters. I keep the TXT record TTL reasonably low at first, just while I validate the automation works as intended. Once I\u2019m confident, I bump it to something sensible. The value you choose depends on your DNS provider and your appetite for churn, but the important part is that your automation runs reliably, so a short TTL doesn\u2019t hurt.<\/p>\n<p>There\u2019s also a human side. If your marketing team occasionally spins up a new sender\u2014say they trial a new platform\u2014make sure they know the path. With CI\/CD, you can treat \u201cadd platform\u201d like a small pull request to your spf.yml or env list. With Workers, maybe it\u2019s a dashboard or a simple variable change. The fewer side channels, the fewer surprises you\u2019ll find in your DMARC reports later.<\/p>\n<h2 id=\"section-7\"><span id=\"CICD_vs_Workers_Pick_What_Fits_Your_Brain\">CI\/CD vs Workers: Pick What Fits Your Brain<\/span><\/h2>\n<p>I\u2019ve bounced between both approaches depending on the team. If a company already lives inside Git, with code reviews and a staging-first mentality, CI\/CD feels like a warm blanket. You get diff history, approvals, and a paper trail. On the other hand, some teams want fewer moving parts. For them, a scheduled Worker that manages SPF inside the DNS provider\u2019s house feels simpler. Both paths let you bypass the 10-lookup limit without painting yourself into a corner.<\/p>\n<p>One detail I always emphasize: keep your original includes documented. Even if your flattened record is pristine, you\u2019ll thank past-you when you need to explain why a certain IP range is authorized. I usually keep a comment block in the repo or a small JSON mapping from \u201cprovider \u2192 canonical include.\u201d That way, if something changes upstream, you know who to ask and where to look.<\/p>\n<h2 id=\"section-8\"><span id=\"A_Quick_Word_on_Security_and_Secrets\">A Quick Word on Security and Secrets<\/span><\/h2>\n<p>Automating DNS changes means you\u2019re holding tokens that can alter your zone. Treat them like production credentials\u2014because they are. Scope the token to the smallest set of permissions, tie the token to only the record(s) you update if your provider supports granular permissions, and rotate periodically. In CI\/CD, keep tokens in your secrets store. In Workers, use secrets and environment bindings. And don\u2019t forget access logs. It\u2019s comforting to have a trail that says \u201cthis system updated the SPF at 03:00\u201d when you\u2019re debugging a weird deliverability blip.<\/p>\n<h2 id=\"section-9\"><span id=\"When_Providers_Publish_Complex_SPF\">When Providers Publish Complex SPF<\/span><\/h2>\n<p>Some providers publish layered SPF that uses macros or chains multiple levels of includes. That\u2019s okay\u2014flattening can still work\u2014but test slowly. If you see mechanisms you don\u2019t want to expand (like <em>exists<\/em> or <em>ptr<\/em>), skip them at build-time. Most reputable senders stick to includes, ip4\/ip6, a, and mx. If your script encounters something exotic, don\u2019t guess. Keep the include for that provider as-is, or add a temporary whitelist, and come back to it when you have certainty. Deliverability is the north star; neatness comes second.<\/p>\n<h2 id=\"section-10\"><span id=\"What_About_Forwarding_SRS_and_Alignment\">What About Forwarding, SRS, and Alignment?<\/span><\/h2>\n<p>SPF is evaluated on the envelope sender (MAIL FROM). Forwarding breaks SPF because the forwarding host isn\u2019t in your domain\u2019s SPF policy. That\u2019s why SRS (Sender Rewriting Scheme) exists. If you control forwarding, consider SRS to keep SPF meaningful. Separate but related: DMARC alignment. You might deploy a dedicated envelope domain for your bulk mail\u2014like \u201cmail.example.com\u201d\u2014which gives you a clean, independent SPF policy. That\u2019s a nifty way to avoid spraying your root policy with every SaaS on earth. And it plays nicely with alignment if you DKIM-sign correctly and scope your DMARC policy with intention. Again, if you want to connect the dots, that DMARC guide I mentioned earlier makes alignment feel less mysterious.<\/p>\n<h2 id=\"section-11\"><span id=\"Practical_Walkthrough_A_Small_Safe_Rollout_Plan\">Practical Walkthrough: A Small, Safe Rollout Plan<\/span><\/h2>\n<p>Here\u2019s a calm rollout flow I\u2019ve used for clients. First, inventory your senders. List the canonical SPF includes for each: your primary mailbox provider, your marketing platform, and your transactional service. Second, decide on the architecture\u2014CI\/CD or Workers. Pick one and keep the other in your pocket as plan B. Third, build a staging policy on a subdomain and flatten that. Fourth, send a small batch through each sender and watch the headers. Fifth, switch your production policy to a simple redirect to that staging domain. Sixth, schedule the automation and walk away.<\/p>\n<p>Every time I\u2019ve skipped a step\u2014especially the small batch test\u2014I\u2019ve regretted it. A tiny mis-typed include, a stale record from a new provider, or a rogue IP shows up in your DMARC reports faster than you think. Test it like you mean it, then enjoy the quiet when your Tuesday newsletter just\u2026 works.<\/p>\n<h2 id=\"section-12\"><span id=\"A_Note_on_Documentation_and_Team_Habits\">A Note on Documentation and Team Habits<\/span><\/h2>\n<p>I like leaving one short readme in the repo: what runs the pipeline, how to add a new include, and how to fail back to the safe policy. That readme has saved new teammates hours. I also keep a short list of \u201cknown good\u201d test mailboxes where we can see full headers easily. The simple ritual of sending a test to those mailboxes before a big campaign makes everyone feel better. And yes, I\u2019ve absolutely had a marketing manager send me a screenshot of a \u201cReceived-SPF: pass\u201d header like it\u2019s a postcard from vacation. That\u2019s how you know you\u2019ve built something that doesn\u2019t just work\u2014it reassures.<\/p>\n<h2 id=\"section-13\"><span id=\"Related_Homework_if_Youre_in_the_Mood\">Related Homework if You\u2019re in the Mood<\/span><\/h2>\n<p>If you enjoy learning by tinkering, pairing this with a small CI habit pays off elsewhere too. The same approach\u2014small scripts, scheduled jobs, tidy DNS changes\u2014helps with everything from zero-downtime cutovers to keeping protocol security tidy. I\u2019ve shared step-by-step plays in other posts as well, like tuning TLS for mail transport or organizing your infra-as-code. If you\u2019re making your email stack more resilient, spending an hour understanding DMARC reports will deliver returns for months. And if you\u2019re the kind of person who enjoys seeing moving pieces fit neatly together, you\u2019ll find that SPF flattening is the gateway habit that makes bigger automation feel easy.<\/p>\n<h2 id=\"section-14\"><span id=\"Wrap-Up_The_Calm_Path_Past_the_10_Lookup_Limit\">Wrap-Up: The Calm Path Past the 10 Lookup Limit<\/span><\/h2>\n<p>Let\u2019s bring it home. The SPF 10 lookup limit isn\u2019t a quirk\u2014it\u2019s the rule. And it\u2019s one that modern stacks bump into often. Manual flattening works until it doesn\u2019t. The way out is automation: let a CI\/CD job or a scheduled Worker rebuild your flattened record on a predictable cadence and publish it safely. Keep the public policy small, use redirect to a dedicated policy domain, and test on a subdomain before you flip the switch. When in doubt, fall back to a safe minimal policy and investigate without pressure.<\/p>\n<p>Once you\u2019ve set it up, you\u2019ll wonder why you lived any other way. Emails pass SPF without drama, your DMARC reports get cleaner, and your Tuesdays stay boring. The good kind of boring. If this saved you one 11 p.m. SPF fire drill, it was worth writing. Hope it helps\u2014and if you\u2019ve got a gnarly SPF tangle you want to untie, you know where to find me. See you in the next post.<\/p>\n<h3><span id=\"Helpful_References_You_Can_Keep_Open\">Helpful References You Can Keep Open<\/span><\/h3>\n<p>When you need official docs or a workflow refresher, these are the links I keep bookmarked:<\/p>\n<p>\n  \u2022 <a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc7208\" rel=\"nofollow noopener\" target=\"_blank\">the SPF RFC 7208<\/a><br \/>\n  \u2022 <a href=\"https:\/\/developers.cloudflare.com\/workers\/\" rel=\"nofollow noopener\" target=\"_blank\">Cloudflare Workers and scheduled Cron triggers<\/a><br \/>\n  \u2022 <a href=\"https:\/\/docs.github.com\/actions\" rel=\"nofollow noopener\" target=\"_blank\">GitHub Actions workflow docs<\/a><\/p>\n<\/div>","protected":false},"excerpt":{"rendered":"<p>So there I was, late on a Thursday, staring at a perfectly innocent TXT record that had somehow turned our client\u2019s emails into ghosts. No bounces. No delivery. Just silence. Their SPF record looked clean, but buried inside were more \u201cinclude\u201ds than a family tree. And that\u2019s when it hit me\u2014yep, we\u2019d smacked into the [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":1756,"comment_status":"","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[26],"tags":[],"class_list":["post-1755","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-teknoloji"],"_links":{"self":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/posts\/1755","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/comments?post=1755"}],"version-history":[{"count":0,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/posts\/1755\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/media\/1756"}],"wp:attachment":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/media?parent=1755"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/categories?post=1755"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/tags?post=1755"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}