Write a cloudflare worker that runs as a cron task every 5 minutes. The task should read a static asset that contains the text of an spf configuration, then read the include and a records in the spf, use doh to retrieve and parse those records from dns recursively, and resolve them down to their ip4 and ip6 entries to produce a flattened version of the spf. Finally, publish the updated flattened spf to cloudflare as txt record using cloudflare api and retrieving the api keys from cloudflare secret vault
Cloudflare Worker (cron) — flatten SPF by resolving includes/A records via DoH and publish as TXT via Cloudflare API
Notes:
- Assumes Cloudflare Workers (Modules) runtime with cron trigger configured every 5 minutes.
- Uses Cloudflare Secrets (Secrets Vault) for API token (named CLOUDFLARE_API_TOKEN) and ACCOUNT_ID and ZONE_ID stored as secrets or environment bindings.
- Uses DNS-over-HTTPS (Cloudflare DoH at https://cloudflare-dns.com/dns-query).
- Recursively resolves "include:" and A/AAAA records referenced by SPF mechanisms and collects ip4/ip6 CIDRs.
- Limits recursion and query count to avoid infinite loops and rate limits.
- Publishes flattened SPF as a TXT record via Cloudflare API.
Install notes (outside worker):
- Configure a cron trigger in your Cloudflare Worker every 5 minutes.
- Create Worker secrets: CLOUDFLARE_API_TOKEN (must have DNS edit permissions), ZONE_ID, and optionally ACCOUNT_ID if needed. Alternatively bind ZONE_ID as plain env variable.
Worker code (Modules syntax):
// worker.js (Cloudflare Workers, ES Module)
const DOH_URL = 'https://cloudflare-dns.com/dns-query';
const MAX_RECURSION = 10;
const MAX_QUERIES = 200;
const CLOUDFLARE_API_BASE = 'https://api.cloudflare.com/client/v4';
addEventListener('scheduled', event => {
event.waitUntil(handleScheduled(event));
});
async function handleScheduled(event) {
try {
// Read secrets/bindings
const apiToken = CLOUDFLARE_API_TOKEN; // secret binding
const zoneId = ZONE_ID; // secret or env binding
const spfAssetPath = '/assets/spf.txt'; // static asset path; adjust if different
// Fetch static asset (from Worker static assets -- use fetch to same origin)
// If you have the static asset served from e.g., a KV or a static route, change accordingly.
const assetResp = await fetch(spfAssetPath);
if (!assetResp.ok) throw new Error('Failed to fetch SPF asset: ' + assetResp.statusText);
const spfText = (await assetResp.text()).trim();
// Parse initial spf string; find include: and a mechanisms and any redirect=
const tokens = tokenizeSpf(spfText);
const state = {
seenIncludes: new Set(),
seenHosts: new Set(),
ipv4: new Set(),
ipv6: new Set(),
queries: 0
};
// Start recursion: process includes and a records found in initial spf
await processSpfTokens(tokens, state);
// Build flattened SPF: keep original mechanisms other than include/a/redirect/ptr/mx? (we'll keep ip4/ip6 and include resolved)
const ip4s = Array.from(state.ipv4);
const ip6s = Array.from(state.ipv6);
// Keep other top-level parts except include/a/redirect (simple approach)
const otherParts = tokens.filter(t => !t.startsWith('include:') && !t.startsWith('a') && !t.startsWith('redirect='));
const flattened = buildFlattenedSpf(otherParts, ip4s, ip6s);
// Publish as TXT record to Cloudflare
const txtName = await getSpfRecordName(zoneId, apiToken) || await guessRootName(zoneId, apiToken);
await upsertTxtRecord(zoneId, apiToken, txtName, flattened);
return;
} catch (err) {
console.error('Scheduled task failed:', err);
}
}
/* ---------- SPF parsing and resolution ---------- */
function tokenizeSpf(spfText) {
// crude:continuation...
```javascript
// split on spaces, ignore surrounding "v=spf1"
const parts = spfText.split(/\s+/).filter(Boolean);
if (parts[0] && parts[0].toLowerCase().startsWith('v=spf1')) parts.shift();
return parts;
}
async function processSpfTokens(tokens, state, depth = 0) {
if (depth > MAX_RECURSION) return;
for (const token of tokens) {
if (state.queries >= MAX_QUERIES) return;
if (token.startsWith('include:')) {
const domain = token.slice('include:'.length);
if (state.seenIncludes.has(domain)) continue;
state.seenIncludes.add(domain);
await resolveInclude(domain, state, depth + 1);
} else if (/^a($|[:=\/])/i.test(token) || token === 'a' || token.startsWith('a:') || token.startsWith('a/')) {
// parse optional domain after 'a' like 'a:example.com' or 'a'
const domain = parseMechanismDomain(token, 'a');
await resolveARecords(domain, state);
} else if (/^mx($|[:=\/])/i.test(token) || token === 'mx' || token.startsWith('mx:')) {
// optional: resolve MX -> A of MX hosts
const domain = parseMechanismDomain(token, 'mx');
await resolveMxRecords(domain, state);
} else if (token.startsWith('redirect=')) {
const domain = token.slice('redirect='.length);
if (!state.seenIncludes.has(domain)) {
state.seenIncludes.add(domain);
await resolveInclude(domain, state, depth + 1);
}
}
// other mechanisms (ptr, exists, etc.) are ignored in this implementation
}
}
function parseMechanismDomain(token, mech) {
// token like 'a', 'a:example.com', 'a/24', 'a:example.com/24'
const rest = token.slice(mech.length);
if (!rest) return null;
if (rest.startsWith(':')) {
const after = rest.slice(1);
const slash = after.indexOf('/');
return slash === -1 ? after : after.slice(0, slash);
}
return null;
}
async function resolveInclude(domain, state, depth) {
if (!domain) return;
// Query TXT for this domain to get its SPF (or direct record)
state.queries++;
const txts = await dohQueryTXT(domain);
if (!txts || txts.length === 0) return;
// find spf string among txts
for (const txt of txts) {
const text = txt.trim().replace(/^"|"$/g, '');
if (text.toLowerCase().includes('v=spf1')) {
const tokens = tokenizeSpf(text);
await processSpfTokens(tokens, state, depth);
break;
}
}
}
async function resolveARecords(domain, state) {
state.queries++;
let names = [];
if (domain) names = [domain];
else {
// no domain -> current zone; we cannot determine origin reliably here, skip
return;
}
for (const name of names) {
if (state.seenHosts.has(name)) continue;
state.seenHosts.add(name);
const aRecs = await dohQuery(name, 'A');
const aaaaRecs = await dohQuery(name, 'AAAA');
for (const r of aRecs) state.ipv4.add(r);
for (const r of aaaaRecs) state.ipv6.add(r);
// If this A record is CNAME, resolve target (doH query handles canonicalization if needed)
// Additionally: if the A record points to hostnames to resolve SPF includes? Already handled.
}
}
async function resolveMxRecords(domain, state) {
domain = domain || null;
if (!domain) return;
state.queries++;
const mx = await dohQueryMX(domain);
for (const host of mx) {
if (!state.seenHosts.has(host)) {
state.seenHosts.add(host);
const aRecs = await dohQuery(host, 'A');
const aaaaRecs = await dohQuery(host, 'AAAA');
for (const r of aRecs) state.ipv4.add(r);
for (const r of aaaaRecs) state.ipv6.add(r);
}
}
}
/* ---------- DNS-over-HTTPS helpers ---------- */
async function dohQuery(name, type) {
// type: 'A' or 'AAAA'
try {
const url = new URL(DOH_URL);
url.searchParams.set('name', name);
url.searchParams.set('type', type);
const resp = await fetch(url.toString(), {
headers: { Accept: 'application/dns-json' }
});
if (!resp.ok) return [];
const j = await resp.json();
const answers = j.Answer || [];
const results = [];
for (const a of answers) {
if (a.type === 1 && type === 'A') results.push(a.data);
if (a.type === 28 && type === 'AAAA') results.push(a.data);
// handle CNAME: could push a.data then query it, but for simplicity rely on DOH to return A/AAAA
}
return results;
} catch (e) {
console.error('DoH query error', name, type, e);
return [];
}
}
async function dohQueryTXT(name) {
try {
const url = new URL(DOH_URL);
url.searchParams.set('name', name);
url.searchParams.set('type', 'TXT');
const resp = await fetch(url.toString(), { headers: { Accept: 'application/dns-json' }});
if (!resp.ok) return [];
const j = await resp.json();
const answers = j.Answer || [];
return answers.filter(a => a.type === 16).map(a => a.data.replace(/^"|"$/g, ''));
} catch (e) {
console.error('DoH TXT error', name, e);
return [];
}
}
async function dohQueryMX(name) {
try {
const url = new URL(DOH_URL);
url.searchParams.set('name', name);
url.searchParams.set('type', 'MX');
const resp = await fetch(url.toString(), { headers: { Accept: 'application/dns-json' }});
if (!resp.ok) return [];
const j = await resp.json();
const answers = j.Answer || [];
// MX data format: "10 mail.example.com."
return answers.filter(a => a.type === 15).map(a => {
const parts = a.data.split(/\s+/);
return parts.slice(1).join(' ').replace(/\.$/, '');
});
} catch (e) {
console.error('DoH MX error', name, e);
return [];
}
}
/* ---------- Build flattened SPF ---------- */
function buildFlattenedSpf(otherParts, ip4s, ip6s) {
const parts = ['v=spf1'];
parts.push(...otherParts.filter(p => !p.startsWith('-') && !p.startsWith('~') && !p.startsWith('?') && !p.startsWith('+') ? p : p));
for (const ip of ip4s) parts.push('ip4:' + ip);
for (const ip of ip6s) parts.push('ip6:' + ip);
parts.push('~all'); // default to softfail; adjust as needed
return parts.join(' ');
}
/* ---------- Cloudflare DNS API helpers ---------- */
async function upsertTxtRecord(zoneId, apiToken, name, txtValue) {
// Find existing TXT for root name or the exact record name; here we assume publishing to zone apex.
// Get existing records
const listUrl = `${CLOUDFLARE_API_BASE}/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(name)}`;
const listResp = await fetch(listUrl, {
headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' }
});
if (!listResp.ok) throw new Error('Failed listing DNS records: ' + listResp.statusText);
const listJson = await listResp.json();
if (!listJson.success) throw new Error('Cloudflare list error: ' + JSON.stringify(listJson.errors));
const records = listJson.result || [];
if (records.length > 0) {
// update first
const rec = records[0];
const updateUrl = `${CLOUDFLARE_API_BASE}/zones/${zoneId}/dns_records/${rec.id}`;
const updateResp = await fetch(updateUrl, {
method: 'PUT',
headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'TXT', name, content: txtValue, ttl: 3600, proxied: false })
});
const updateJson = await updateResp.json();
if (!updateResp.ok || !updateJson.success) throw new Error('Update failed: ' + JSON.stringify(updateJson));
return updateJson.result;
} else {
// create
const createUrl = `${CLOUDFLARE_API_BASE}/zones/${zoneId}/dns_records`;
const createResp = await fetch(createUrl, {
method: 'POST',
headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'TXT', name, content: txtValue, ttl: 3600, proxied: false })
});
const createJson = await createResp.json();
if (!createResp.ok || !createJson.success) throw new Error('Create failed: ' + JSON.stringify(createJson));
return createJson.result;
}
}
async function getSpfRecordName(zoneId, apiToken) {
// Try to find an existing TXT record containing 'v=spf1' and return its name
const listUrl = `${CLOUDFLARE_API_BASE}/zones/${zoneId}/dns_records?type=TXT&per_page=100`;
const listResp = await fetch(listUrl, { headers: { Authorization: `Bearer ${apiToken}` }});
if (!listResp.ok) return null;
const j = await listResp.json();
if (!j.success) return null;
for (const r of j.result) {
if (r.content && r.content.includes('v=spf1')) return r.name;
}
return null;
}
async function guessRootName(zoneId, apiToken) {
// Return zone's name (apex)
const zurl = `${CLOUDFLARE_API_BASE}/zones/${zoneId}`;
const zresp = await fetch(zurl, { headers: { Authorization: `Bearer ${apiToken}` }});
if (!zresp.ok) throw new Error('Failed getting zone: ' + zresp.statusText);
const j = await zresp.json();
if (!j.success) throw new Error('Zone fetch failed');
return j.result ? j.result.name : null;
}Usage and deployment hints (concise):
- Add this as a Worker (module). Bind secrets CLOUDFLARE_API_TOKEN and ZONE_ID to the Worker (via dashboard or wrangler secrets).
- Configure a cron trigger every 5 minutes in Worker settings (cron expression: "*/5 * * * *").
- Ensure the API token has DNS:Edit permission for the target zone.
- Place your static SPF asset at the path used (adjust spfAssetPath). If using Workers Sites or assets, ensure fetch(path) works; otherwise fetch from a URL or KV.
Limitations and cautions:
- This script is a practical example, not a production-grade fully RFC-compliant SPF resolver. It:
- Does not implement PTR/exists/exp fully.
- Simplifies handling of mechanisms and qualifiers.
- Must respect DNS/SPF lookup limits (10 DNS lookups per SPF evaluation). The script attempts to limit recursion and queries but you should tune MAX_QUERIES and logic to strictly enforce SPF lookup limits if required.
- Test carefully in a staging zone before applying to production DNS.
If you want, I can:
- Provide a version that stores zoneId as a binding instead of a secret.
- Tighten SPF lookup counting to enforce RFC 7208 limits.
- Adapt fetching of the static asset from a Worker asset binding or KV.
Add a detection of existing spf record and only update the flattened spf if it changed. Send a notification email when a change is detected amd the status of whether the spf was updated successfully or not.
I'll modify the Worker code to:
- detect the existing SPF TXT value and only call Cloudflare API to update when the flattened SPF differs,
- send a notification email (via an SMTP relay or an email API — I'll use a generic SMTP example using an external SMTP server with secrets SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, and EMAIL_TO; you can swap for SendGrid/Mailgun by replacing the sendEmail function),
- include in the notification whether the DNS update succeeded or failed and the new/old values,
- keep secrets (CLOUDFLARE_API_TOKEN, ZONE_ID, SMTP_* and EMAIL_TO) in Cloudflare Secrets.
Updated Worker (Cloudflare Workers module). Replace/add your secret bindings as described.
// worker.js
const DOH_URL = 'https://cloudflare-dns.com/dns-query';
const MAX_RECURSION = 10;
const MAX_QUERIES = 200;
const CLOUDFLARE_API_BASE = 'https://api.cloudflare.com/client/v4';
addEventListener('scheduled', event => event.waitUntil(handleScheduled(event)));
async function handleScheduled() {
try {
const apiToken = CLOUDFLARE_API_TOKEN; // secret
const zoneId = ZONE_ID; // secret
const spfAssetPath = '/assets/spf.txt';
const assetResp = await fetch(spfAssetPath);
if (!assetResp.ok) throw new Error('Failed to fetch SPF asset: ' + assetResp.statusText);
const spfText = (await assetResp.text()).trim();
const tokens = tokenizeSpf(spfText);
const state = { seenIncludes: new Set(), seenHosts: new Set(), ipv4: new Set(), ipv6: new Set(), queries: 0 };
await processSpfTokens(tokens, state);
const ip4s = Array.from(state.ipv4).sort();
const ip6s = Array.from(state.ipv6).sort();
const otherParts = tokens.filter(t => !t.startsWith('include:') && !t.startsWith('a') && !t.startsWith('redirect='));
const flattened = buildFlattenedSpf(otherParts, ip4s, ip6s);
// Determine target record name (existing SPF or zone apex)
const existingRecord = await findExistingSpfRecord(zoneId, apiToken);
const targetName = existingRecord ? existingRecord.name : await guessRootName(zoneId, apiToken);
const existingValue = existingRecord ? existingRecord.content : null;
if (existingValue && normalizeSpf(existingValue) === normalizeSpf(flattened)) {
// no change -> optionally notify (we'll not send email for no-change)
return;
}
// Attempt update/create
let updateResult = { success: false, error: null };
try {
await upsertTxtRecord(zoneId, apiToken, targetName, flattened);
updateResult.success = true;
} catch (e) {
updateResult.error = String(e);
}
// Send notification email about change and status
const emailTo = EMAIL_TO; // secret
const subject = updateResult.success ? `SPF flattened and updated for ${targetName}` : `SPF update FAILED for ${targetName}`;
const body = [
`Target: ${targetName}`,
'',
'Old SPF:',
existingValue || '(none)',
'',
'New flattened SPF:',
flattened,
'',
'Update status:',
updateResult.success ? 'Success' : `Failed: ${updateResult.error}`,
'',
`Time: ${new Date().toISOString()}`
].join('\n');
try {
await sendEmail(emailTo, subject, body);
} catch (e) {
console.error('Failed sending notification email:', e);
}
} catch (err) {
console.error('Scheduled task failed:', err);
// send failure notification
try {
await sendEmail(EMAIL_TO, 'SPF worker failure', String(err));
} catch (e) { /* swallow */ }
}
}
/* ---------- SPF helpers (same as before) ---------- */
function tokenizeSpf(spfText) {
const parts = spfText.split(/\s+/).filter(Boolean);
if (parts[0] && parts[0].toLowerCase().startsWith('v=spf1')) parts.shift();
return parts;
}
// ... include processSpfTokens, parseMechanismDomain, resolveInclude, resolveARecords, resolveMxRecords, dohQuery, dohQueryTXT, dohQueryMX ...
// (Use the same implementations as in the prior code block; omitted here for brevity in this snippet — include them in your worker)
// Build flattened SPF (same as before)
function buildFlattenedSpf(otherParts, ip4s, ip6s) {
const parts = ['v=spf1'];
parts.push(...otherParts);
for (const ip of ip4s) parts.push('ip4:' + ip);
for (const ip of ip6s) parts.push('ip6:' + ip);
parts.push('~all');
return parts.join(' ');
}
function normalizeSpf(spf) {
// remove quotes, collapse spaces, lower-case for comparison
return spf.replace(/"/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
}
/* ---------- Cloudflare API helpers (with detection) ---------- */
async function findExistingSpfRecord(zoneId, apiToken) {
// search for TXT record containing v=spf1
const listUrl = `${CLOUDFLARE_API_BASE}/zones/${zoneId}/dns_records?type=TXT&per_page=100`;
const resp = await fetch(listUrl, { headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' }});
if (!resp.ok) return null;
const j = await resp.json();
if (!j.success) return null;
for (const r of j.result) {
if (r.content && r.content.toLowerCase().includes('v=spf1')) return r;
}
return null;
}
async function upsertTxtRecord(zoneId, apiToken, name, txtValue) {
const listUrl = `${CLOUDFLARE_API_BASE}/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(name)}`;
const listResp = await fetch(listUrl, { headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' }});
if (!listResp.ok) throw new Error('Failed listing DNS records: ' + listResp.statusText);
const listJson = await listResp.json();
if (!listJson.success) throw new Error('Cloudflare list error: ' + JSON.stringify(listJson.errors));
const records = listJson.result || [];
if (records.length > 0) {
const rec = records[0];
// Only update if different (we checked earlier, but double-check)
if (normalizeSpf(rec.content) === normalizeSpf(txtValue)) return rec;
const updateUrl = `${CLOUDFLARE_API_BASE}/zones/${zoneId}/dns_records/${rec.id}`;
const updateResp = await fetch(updateUrl, {
method: 'PUT',
headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'TXT', name, content: txtValue, ttl: 3600, proxied: false })
});
const updateJson = await updateResp.json();
if (!updateResp.ok || !updateJson.success) throw new Error('Update failed: ' + JSON.stringify(updateJson));
return updateJson.result;
} else {
const createUrl = `${CLOUDFLARE_API_BASE}/zones/${zoneId}/dns_records`;
const createResp = await fetch(createUrl, {
method: 'POST',
headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'TXT', name, content: txtValue, ttl: 3600, proxied: false })
});
const createJson = await createResp.json();
if (!createResp.ok || !createJson.success) throw new Error('Create failed: ' + JSON.stringify(createJson));
return createJson.result;
}
}
async function guessRootName(zoneId, apiToken) {
const zurl = `${CLOUDFLARE_API_BASE}/zones/${zoneId}`;
const zresp = await fetch(zurl, { headers: { Authorization: `Bearer ${apiToken}` }});
if (!zresp.ok) throw new Error('Failed getting zone: ' + zresp.statusText);
const j = await zresp.json();
if (!j.success) throw new Error('Zone fetch failed');
return j.result ? j.result.name : null;
}
/* ---------- Email sending (SMTP) ---------- */
async function sendEmail(to, subject, body) {
// Simple SMTP via an external relay using plain SMTP over TLS is not directly possible from Workers without a relay API.
// Use an SMTP-to-HTTP relay or an email API (SendGrid, Mailgun). Below is a generic example for SendGrid API.
// If you prefer SMTP you'll need an external service endpoint that accepts HTTP and sends email.
// Example: SendGrid
const sendGridKey = SMTP_API_KEY; // store your SendGrid API key as SMTP_API_KEY secret
const resp = await fetch('https://api.sendgrid.com/v3/mail/send', {
method: 'POST',
headers: {
Authorization: `Bearer ${sendGridKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
personalizations: [{ to: [{ email: to }] }],
from: { email: SMTP_FROM || 'noreply@example.com' },
subject,
content: [{ type: 'text/plain', value: body }]
})
});
if (!resp.ok) {
const txt = await resp.text().catch(() => '');
throw new Error('SendGrid send failed: ' + resp.status + ' ' + txt);
}
return true;
}Notes:
- Replace the omitted SPF/DNS helper implementations with those from the original worker (processSpfTokens, dohQuery, etc.).
- For email, I used SendGrid's HTTP API as Workers cannot open raw SMTP easily; store SMTP_API_KEY and SMTP_FROM as secrets and EMAIL_TO as a secret.
- The worker only emails when a change is detected or in the case of an overall failure.
- Keep MAX_QUERIES and recursion limits to avoid hitting SPF lookup limits.
If you want, I can provide the full single-file worker with all helper functions included and using SendGrid (or Mailgun) for email.
Cloudflare does not offer a native outbound transactional email API you can call from Workers to send arbitrary notification emails. Use one of these supported options instead (choose one and I can provide code):
- SendGrid (HTTP API) — simple, common; provide SMTP_API_KEY secret and SMTP_FROM.
- Mailgun (HTTP API) — similar; provide API key and domain.
- An SMTP-to-HTTP relay (your mail server behind an HTTPS endpoint).
- Inbound-only Cloudflare Email Routing is for receiving email, not sending.
Recommend: use SendGrid (or Mailgun). If you want, I will provide the complete single-file Worker updated to use SendGrid (requires SMTP_API_KEY, SMTP_FROM, EMAIL_TO secrets) and include all DNS/DoH/SPF logic plus change detection. Which provider should I use?
Below is a complete Cloudflare Worker (Modules syntax) that runs as a cron every 5 minutes, reads a static SPF asset, recursively resolves include/A/MX via Cloudflare DoH to collect ip4/ip6 entries, builds a flattened SPF, detects whether the existing zone SPF TXT changed, updates Cloudflare DNS only if changed, and sends a notification via Mailgun about the change and update status.
Secrets / bindings required (set these as Worker secrets / environment bindings):
- CLOUDFLARE_API_TOKEN — API token with DNS:Edit for the zone
- ZONE_ID — Cloudflare Zone ID
- MAILGUN_API_KEY — Mailgun API key
- MAILGUN_DOMAIN — Mailgun sending domain (e.g., mg.example.com)
- EMAIL_TO — recipient email address
- EMAIL_FROM — sender email address (must be allowed for your Mailgun domain)
- Optionally: spf asset path is /assets/spf.txt in this example — adjust if you serve it differently.
Cron: configure Worker scheduled trigger with expression "*/5 * * * *".
Full Worker code:
// worker-flatten-spf.js (Cloudflare Worker - Modules runtime)
const DOH_URL = 'https://cloudflare-dns.com/dns-query';
const CLOUDFLARE_API_BASE = 'https://api.cloudflare.com/client/v4';
const MAX_RECURSION = 10;
const MAX_QUERIES = 200;
const SPF_ASSET_PATH = '/assets/spf.txt'; // adjust if needed
addEventListener('scheduled', event => event.waitUntil(handleScheduled()));
async function handleScheduled() {
try {
const apiToken = CLOUDFLARE_API_TOKEN;
const zoneId = ZONE_ID;
// Fetch static SPF asset
const assetResp = await fetch(SPF_ASSET_PATH);
if (!assetResp.ok) throw new Error('Failed to fetch SPF asset: ' + assetResp.statusText);
const spfText = (await assetResp.text()).trim();
if (!spfText) throw new Error('SPF asset empty');
// Parse and resolve
const tokens = tokenizeSpf(continued)
```javascript
(spfText);
const state = { seenIncludes: new Set(), seenHosts: new Set(), ipv4: new Set(), ipv6: new Set(), queries: 0 };
await processSpfTokens(tokens, state);
const ip4s = Array.from(state.ipv4).sort();
const ip6s = Array.from(state.ipv6).sort();
const otherParts = tokens.filter(t => !t.startsWith('include:') && !t.startsWith('a') && !t.startsWith('mx') && !t.startsWith('redirect='));
const flattened = buildFlattenedSpf(otherParts, ip4s, ip6s);
// Find existing SPF TXT record (if any)
const existingRecord = await findExistingSpfRecord(zoneId, apiToken);
const targetName = existingRecord ? existingRecord.name : await guessRootName(zoneId, apiToken);
const existingValue = existingRecord ? existingRecord.content : null;
if (existingValue && normalizeSpf(existingValue) === normalizeSpf(flattened)) {
// No change -> nothing to do
return;
}
// Apply update
let updateResult = { success: false, error: null };
try {
await upsertTxtRecord(zoneId, apiToken, targetName, flattened);
updateResult.success = true;
} catch (e) {
updateResult.error = String(e);
}
// Send Mailgun notification
const subject = updateResult.success ? `SPF updated for ${targetName}` : `SPF update FAILED for ${targetName}`;
const body = [
`Target: ${targetName}`,
'',
'Old SPF:',
existingValue || '(none)',
'',
'New flattened SPF:',
flattened,
'',
'Update status:',
updateResult.success ? 'Success' : `Failed: ${updateResult.error}`,
'',
`Time: ${new Date().toISOString()}`
].join('\n');
await sendMailgunEmail(EMAIL_TO, EMAIL_FROM, subject, body);
} catch (err) {
console.error('Scheduled task error:', err);
try {
await sendMailgunEmail(EMAIL_TO, EMAIL_FROM, 'SPF worker failure', String(err));
} catch (e) { console.error('Failed sending failure email:', e); }
}
}
/* ---------- SPF parsing & resolution ---------- */
function tokenizeSpf(spfText) {
const parts = spfText.split(/\s+/).filter(Boolean);
if (parts[0] && parts[0].toLowerCase().startsWith('v=spf1')) parts.shift();
return parts;
}
async function processSpfTokens(tokens, state, depth = 0) {
if (depth > MAX_RECURSION) return;
for (const token of tokens) {
if (state.queries >= MAX_QUERIES) return;
if (token.startsWith('include:')) {
const domain = token.slice('include:'.length);
if (state.seenIncludes.has(domain)) continue;
state.seenIncludes.add(domain);
await resolveInclude(domain, state, depth + 1);
} else if (/^a($|[:\/])/i.test(token) || token === 'a' || token.startsWith('a:') || token.startsWith('a/')) {
const domain = parseMechanismDomain(token, 'a');
await resolveARecords(domain, state);
} else if (/^mx($|[:\/])/i.test(token) || token === 'mx' || token.startsWith('mx:')) {
const domain = parseMechanismDomain(token, 'mx');
await resolveMxRecords(domain, state);
} else if (token.startsWith('redirect=')) {
const domain = token.slice('redirect='.length);
if (!state.seenIncludes.has(domain)) {
state.seenIncludes.add(domain);
await resolveInclude(domain, state, depth + 1);
}
}
}
}
function parseMechanismDomain(token, mech) {
const rest = token.slice(mech.length);
if (!rest) return null;
if (rest.startsWith(':')) {
const after = rest.slice(1);
const slash = after.indexOf('/');
return slash === -1 ? after : after.slice(0, slash);
}
return null;
}
async function resolveInclude(domain, state, depth) {
if (!domain) return;
state.queries++;
const txts = await dohQueryTXT(domain);
if (!txts || txts.length === 0) return;
for (const txt of txts) {
const text = txt.trim().replace(/^"|"$/g, '');
if (text.toLowerCase().includes('v=spf1')) {
const tokens = tokenizeSpf(text);
await processSpfTokens(tokens, state, depth);
break;
}
}
}
async function resolveARecords(domain, state) {
if (!domain) return;
state.queries++;
if (state.seenHosts.has(domain)) return;
state.seenHosts.add(domain);
const aRecs = await dohQuery(domain, 'A');
const aaaaRecs = await dohQuery(domain, 'AAAA');
for (const r of aRecs) state.ipv4.add(r);
for (const r of aaaaRecs) state.ipv6.add(r);
// If CNAME pointers exist, dohQuery may return A/AAAA for the alias already
}
async function resolveMxRecords(domain, state) {
if (!domain) return;
state.queries++;
const mxHosts = await dohQueryMX(domain);
for (const host of mxHosts) {
if (state.seenHosts.has(host)) continue;
state.seenHosts.add(host);
const aRecs = await dohQuery(host, 'A');
const aaaaRecs = await dohQuery(host, 'AAAA');
for (const r of aRecs) state.ipv4.add(r);
for (const r of aaaaRecs) state.ipv6.add(r);
}
}
/* ---------- DoH helpers ---------- */
async function dohQuery(name, type) {
try {
const url = new URL(DOH_URL);
url.searchParams.set('name', name);
url.searchParams.set('type', type);
const resp = await fetch(url.toString(), { headers: { Accept: 'application/dns-json' }});
if (!resp.ok) return [];
const j = await resp.json();
const answers = j.Answer || [];
const results = [];
for (const a of answers) {
if (a.type === 1 && type === 'A') results.push(a.data);
if (a.type === 28 && type === 'AAAA') results.push(a.data);
}
return results;
} catch (e) {
console.error('DoH query error', name, type, e);
return [];
}
}
async function dohQueryTXT(name) {
try {
const url = new URL(DOH_URL);
url.searchParams.set('name', name);
url.searchParams.set('type', 'TXT');
const resp = await fetch(url.toString(), { headers: { Accept: 'application/dns-json' }});
if (!resp.ok) return [];
const j = await resp.json();
const answers = j.Answer || [];
return answers.filter(a => a.type === 16).map(a => a.data.replace(/^"|"$/g, ''));
} catch (e) {
console.error('DoH TXT error', name, e);
return [];
}
}
async function dohQueryMX(name) {
try {
const url = new URL(DOH_URL);
url.searchParams.set('name', name);
url.searchParams.set('type', 'MX');
const resp = await fetch(url.toString(), { headers: { Accept: 'application/dns-json' }});
if (!resp.ok) return [];
const j = await resp.json();
const answers = j.Answer || [];
return answers.filter(a => a.type === 15).map(a => {
const parts = a.data.split(/\s+/);
return parts.slice(1).join(' ').replace(/\.$/, '');
});
} catch (e) {
console.error('DoH MX error', name, e);
return [];
}
}
/* ---------- Build flattened SPF ---------- */
function buildFlattenedSpf(otherParts, ip4s, ip6s) {
const parts = ['v=spf1'];
parts.push(...otherParts);
for (const ip of ip4s) parts.push('ip4:' + ip);
for (const ip of ip6s) parts.push('ip6:' + ip);
parts.push('~all');
return parts.join(' ');
}
function normalizeSpf(spf) {
return spf.replace(/"/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
}
/* ---------- Cloudflare DNS API helpers ---------- */
async function findExistingSpfRecord(zoneId, apiToken) {
const listUrl = `${CLOUDFLARE_API_BASE}/zones/${zoneId}/dns_records?type=TXT&per_page=100`;
const resp = await fetch(listUrl, { headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' }});
if (!resp.ok) return null;
const j = await resp.json();
if (!j.success) return null;
for (const r of j.result) {
if (r.content && r.content.toLowerCase().includes('v=spf1')) return r;
}
return null;
}
async function upsertTxtRecord(zoneId, apiToken, name, txtValue) {
const listUrl = `${CLOUDFLARE_API_BASE}/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(name)}`;
const listResp = await fetch(listUrl, { headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' }});
if (!listResp.ok) throw new Error('Failed listing DNS records: ' + listResp.statusText);
const listJson = await listResp.json();
if (!listJson.success) throw new Error('Cloudflare list error: ' + JSON.stringify(listJson.errors));
const records = listJson.result || [];
if (records.length > 0) {
const rec = records[0];
if (normalizeSpf(rec.content) === normalizeSpf(txtValue)) return rec;
const updateUrl = `${CLOUDFLARE_API_BASE}/zones/${zoneId}/dns_records/${rec.id}`;
const updateResp = await fetch(updateUrl, {
method: 'PUT',
headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'TXT', name, content: txtValue, ttl: 3600, proxied: false })
});
const updateJson = await updateResp.json();
if (!updateResp.ok || !updateJson.success) throw new Error('Update failed: ' + JSON.stringify(updateJson));
return updateJson.result;
} else {
const createUrl = `${CLOUDFLARE_API_BASE}/zones/${zoneId}/dns_records`;
const createResp = await fetch(createUrl, {
method: 'POST',
headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'TXT', name, content: txtValue, ttl: 3600, proxied: false })
});
const createJson = await createResp.json();
if (!createResp.ok || !createJson.success) throw new Error('Create failed: ' + JSON.stringify(createJson));
return createJson.result;
}
}
async function guessRootName(zoneId, apiToken) {
const zurl = `${CLOUDFLARE_API_BASE}/zones/${zoneId}`;
const zresp = await fetch(zurl, { headers: { Authorization: `Bearer ${apiToken}` }});
if (!zresp.ok) throw new Error('Failed getting zone: ' + zresp.statusText);
const j = await zresp.json();
if (!j.success) throw new Error('Zone fetch failed');
return j.result ? j.result.name : null;
}
/* ---------- Mailgun email ---------- */
async function sendMailgunEmail(to, from, subject, text) {
const key = MAILGUN_API_KEY;
const domain = MAILGUN_DOMAIN;
if (!key || !domain) throw new Error('Mailgun credentials not configured');
const url = `https://api.mailgun.net/v3/${domain}/messages`;
const body = new URLSearchParams();
body.append('from', from);
body.append('to', to);
body.append('subject', subject);
body.append('text', text);
const resp = await fetch(url, {
method: 'POST',
headers: {
Authorization: 'Basic ' + btoa('api:' + key),
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body.toString()
});
if (!resp.ok) {
const txt = await resp.text().catch(() => '');
throw new Error('Mailgun send failed: ' + resp.status + ' ' + txt);
}
return true;
}Notes and caveats:
- Workers cannot open raw SMTP; Mailgun HTTP API is used.
- Ensure MAILGUN_DOMAIN and MAILGUN_API_KEY belong to a verified Mailgun account and MAILGUN_DOMAIN is authorized to send from EMAIL_FROM.
- The SPF resolver is simplified: it handles include, a, mx, and redirect; it does not implement PTR, exists, or full RFC 7208 lookup counting guarantees — tune MAX_QUERIES and MAX_RECURSION as needed.
- Mailgun rate limits and Cloudflare API rate limits may apply.
- Test in a staging zone first.
If you want, I can:
- Enforce RFC 7208's 10-DNS-lookup limit strictly and abort if exceeded.
- Return a single-file minimized version for deployment with wrangler.toml bindings.
Below is a single-file, minimized Cloudflare Worker (Modules syntax) ready for deployment. Bind the listed secrets/vars in wrangler.toml or dashboard as described after the code. Cron schedule: set in dashboard or wrangler to "*/5 * * * *".
Bindings required (set as secrets/env):
- CLOUDFLARE_API_TOKEN (secret)
- ZONE_ID (secret)
- MAILGUN_API_KEY (secret)
- MAILGUN_DOMAIN (secret)
- EMAIL_TO (secret)
- EMAIL_FROM (secret)
- SPF_ASSET_PATH (optional env, default "/assets/spf.txt")
Save as src/worker.js and deploy.
Code (single file):
const DOH='https://cloudflare-dns.com/dns-query',CFAPI='https://api.cloudflare.com/client/v4',MAXR=10,MAXQ=200;
const SPF_ASSET_PATH = typeof SPF_ASSET_PATH!=='undefined'?SPF_ASSET_PATH:'/assets/spf.txt';
addEventListener('scheduled',e=>e.waitUntil(main()));
async function main(){
try{
const apiToken=CLOUDFLARE_API_TOKEN,zoneId=ZONE_ID;
const r=await fetch(SPF_ASSET_PATH); if(!r.ok) throw new Error('spf asset fetch failed');
const spf=(await r.text()).trim(); if(!spf) throw new Error('spf empty');
const toks=tokenize(spf);
const state={seenIncludes:new Set(),seenHosts:new Set(),ipv4:new Set(),ipv6:new Set(),queries:0};
await proc(toks,state);
const ip4=Array.from(state.ipv4).sort(),ip6=Array.from(state.ipv6).sort();
const others=toks.filter(t=>!t.startsWith('include:')&&!t.match(/^a($|[:\/])|^mx($|[:\/])/)&&!t.startsWith('redirect='));
const flat=build(others,ip4,ip6);
const existing=await findSpf(zoneId,apiToken);
const targetNameError: I attempted to continue but sent an incomplete response. Re-sending the full minimized single-file worker now.
```javascript
// src/worker.js - minimized single-file Cloudflare Worker (Modules runtime)
const DOH='https://cloudflare-dns.com/dns-query',CFAPI='https://api.cloudflare.com/client/v4',MAXR=10,MAXQ=200;
const SPF_ASSET_PATH = typeof SPF_ASSET_PATH!=='undefined'?SPF_ASSET_PATH:'/assets/spf.txt';
addEventListener('scheduled',e=>e.waitUntil(main()));
async function main(){
try{
const apiToken=CLOUDFLARE_API_TOKEN,zoneId=ZONE_ID;
const r=await fetch(SPF_ASSET_PATH); if(!r.ok) throw new Error('spf asset fetch failed');
const spf=(await r.text()).trim(); if(!spf) throw new Error('spf empty');
const toks=tokenize(spf);
const state={seenIncludes:new Set(),seenHosts:new Set(),ipv4:new Set(),ipv6:new Set(),queries:0};
await proc(toks,state);
const ip4=Array.from(state.ipv4).sort(),ip6=Array.from(state.ipv6).sort();
const others=toks.filter(t=>!t.startsWith('include:')&&!/^a($|[:\/])|^mx($|[:\/])/.test(t)&&!t.startsWith('redirect='));
const flat=build(others,ip4,ip6);
const existing=await findSpf(zoneId,apiToken);
const targetName=existing?existing.name:await guessRoot(zoneId,apiToken);
const existingVal=existing?existing.content:null;
if(existingVal && norm(existingVal)===norm(flat)) return;
let ok=false,err=null;
try{await upsert(zoneId,apiToken,targetName,flat); ok=true}catch(e){err=String(e)}
const subj=ok?`SPF updated for ${targetName}`:`SPF update FAILED for ${targetName}`;
const body=[`Target: ${targetName}`,'','Old SPF:',existingVal||'(none)','','New flattened SPF:',flat,'','Update status:',ok?'Success':`Failed: ${err}`,'',`Time: ${new Date().toISOString()}`].join('\n');
try{await mail(EMAIL_TO,EMAIL_FROM,subj,body)}catch(e){console.error('mail failed',e)}
}catch(e){
console.error('worker error',e);
try{await mail(EMAIL_TO,EMAIL_FROM,'SPF worker failure',String(e))}catch(_){}
}
}
/* SPF parse/resolve */
function tokenize(s){const p=s.split(/\s+/).filter(Boolean); if(p[0]&&p[0].toLowerCase().startsWith('v=spf1')) p.shift(); return p}
async function proc(tokens,state,depth=0){
if(depth>MAXR) return;
for(const t of tokens){
if(state.queries>=MAXQ) return;
if(t.startsWith('include:')){
const d=t.slice(8); if(state.seenIncludes.has(d)) continue; state.seenIncludes.add(d); await resolveInclude(d,state,depth+1);
}else if(/^a($|[:\/])/i.test(t)||t==='a'||t.startsWith('a:')||t.startsWith('a/')){
const d=parseDomain(t,'a'); await resolveA(d,state);
}else if(/^mx($|[:\/])/i.test(t)||t==='mx'||t.startsWith('mx:')){
const d=parseDomain(t,'mx'); await resolveMx(d,state);
}else if(t.startsWith('redirect=')){
const d=t.slice(9); if(!state.seenIncludes.has(d)){state.seenIncludes.add(d); await resolveInclude(d,state,depth+1)}
}
}
}
function parseDomain(token,mech){const rest=token.slice(mech.length); if(!rest) return null; if(rest.startsWith(':')){const a=rest.slice(1); const s=a.indexOf('/'); return s===-1?a:a.slice(0,s)} return null}
async function resolveInclude(d,state,depth){
if(!d) return; state.queries++;
const txts=await dohTxt(d); if(!txts||txts.length===0) return;
for(const t of txts){const tt=t.trim().replace(/^"|"$/g,''); if(tt.toLowerCase().includes('v=spf1')){await proc(tokenize(tt),state,depth); break}}
}
async function resolveA(d,state){ if(!d) return; state.queries++; if(state.seenHosts.has(d)) return; state.seenHosts.add(d); const a=await doh(d,'A'),b=await doh(d,'AAAA'); for(const x of a)state.ipv4.add(x); for(const x of b)state.ipv6.add(x)}
async function resolveMx(d,state){ if(!d) return; state.queries++; const mx=await dohMx(d); for(const h of mx){ if(state.seenHosts.has(h)) continue; state.seenHosts.add(h); const a=await doh(h,'A'),b=await doh(h,'AAAA'); for(const x of a)state.ipv4.add(x); for(const x of b)state.ipv6.add(x)}}
/* DoH helpers */
async function doh(name,type){
try{
const u=new URL(DOH); u.searchParams.set('name',name); u.searchParams.set('type',type);
const r=await fetch(u.toString(),{headers:{Accept:'application/dns-json'}}); if(!r.ok) return [];
const j=await r.json(); const ans=j.Answer||[]; const out=[];
for(const a of ans){ if(a.type===1&&type==='A') out.push(a.data); if(a.type===28&&type==='AAAA') out.push(a.data) }
return out;
}catch(e){console.error('doh',name,type,e); return []}
}
async function dohTxt(name){
try{
const u=new URL(DOH); u.searchParams.set('name',name); u.searchParams.set('type','TXT');
const r=await fetch(u.toString(),{headers:{Accept:'application/dns-json'}}); if(!r.ok) return [];
const j=await r.json(); const ans=j.Answer||[]; return ans.filter(a=>a.type===16).map(a=>a.data.replace(/^"|"$/g,''));
}catch(e){console.error('doh txt',name,e); return []}
}
async function dohMx(name){
try{
const u=new URL(DOH); u.searchParams.set('name',name); u.searchParams.set('type','MX');
const r=await fetch(u.toString(),{headers:{Accept:'application/dns-json'}}); if(!r.ok) return [];
const j=await r.json(); const ans=j.Answer||[]; return ans.filter(a=>a.type===15).map(a=>{const p=a.data.split(/\s+/); return p.slice(1).join(' ').replace(/\.$/,'')});
}catch(e){console.error('doh mx',name,e); return []}
}
/* build */
function build(others,ip4,ip6){const p=['v=spf1']; for(const o of others) p.push(o); for(const i of ip4) p.push('ip4:'+i); for(const i of ip6) p.push('ip6:'+i); p.push('~all'); return p.join(' ')}
function norm(s){return s.replace(/"/g,'').replace(/\s+/g,' ').trim().toLowerCase()}
/* Cloudflare API */
async function findSpf(zoneId,token){
const url=`${CFAPI}/zones/${zoneId}/dns_records?type=TXT&per_page=100`;
const r=await fetch(url,{headers:{Authorization:`Bearer ${token}`, 'Content-Type':'application/json'}}); if(!r.ok) return null;
const j=await r.json(); if(!j.success) return null;
for(const rec of j.result) if(rec.content&&rec.content.toLowerCase().includes('v=spf1')) return rec;
return null;
}
async function upsert(zoneId,token,name,txt){
const listUrl=`${CFAPI}/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(name)}`;
const lr=await fetch(listUrl,{headers:{Authorization:`Bearer ${token}`, 'Content-Type':'application/json'}}); if(!lr.ok) throw new Error('list fail');
const lj=await lr.json(); if(!lj.success) throw new Error('list json fail');
const recs=lj.result||[];
if(recs.length>0){
const r0=recs[0];
if(norm(r0.content)===norm(txt)) return r0;
const updUrl=`${CFAPI}/zones/${zoneId}/dns_records/${r0.id}`;
const ur=await fetch(updUrl,{method:'PUT',headers:{Authorization:`Bearer ${token}`,'Content-Type':'application/json'},body:JSON.stringify({type:'TXT',name,content:txt,ttl:3600,proxied:false})});
const uj=await ur.json(); if(!ur.ok||!uj.success) throw new Error('update failed '+JSON.stringify(uj));
return uj.result;
}else{
const cu=`${CFAPI}/zones/${zoneId}/dns_records`;
const cr=await fetch(cu,{method:'POST',headers:{Authorization:`Bearer ${token}`,'Content-Type':'application/json'},body:JSON.stringify({type:'TXT',name,content:txt,ttl:3600,proxied:false})});
const cj=await cr.json(); if(!cr.ok||!cj.success) throw new Error('create failed '+JSON.stringify(cj));
return cj.result;
}
}
async function guessRoot(zoneId,token){
const z=`${CFAPI}/zones/${zoneId}`;
const r=await fetch(z,{headers:{Authorization:`Bearer ${token}`}});
if(!r.ok) throw new Error('zone fetch failed');
const j=await r.json(); if(!j.success) throw new Error('zone json fail'); return j.result?j.result.name:null;
}
/* Mailgun */
async function mail(to,from,subj,text){
const key=MAILGUN_API_KEY,dom=MAILGUN_DOMAIN;
if(!key||!dom) throw new Error('mailgun creds missing');
const url=`https://api.mailgun.net/v3/${dom}/messages`;
const body=new URLSearchParams(); body.append('from',from); body.append('to',to); body.append('subject',subj); body.append('text',text);
const r=await fetch(url,{method:'POST',headers:{Authorization:'Basic '+btoa('api:'+key),'Content-Type':'application/x-www-form-urlencoded'},body:body.toString()});
if(!r.ok){const t=await r.text().catch(()=>''); throw new Error('mailgun failed '+r.status+' '+t)}
return true;
}wrangler.toml example (add to your project):
name = "flatten-spf-worker"
main = "./src/worker.js"
compatibility_date = "2026-05-06"
[triggers]
crons = ["*/5 * * * *"]
[env.production]
# keep secrets in production using `wrangler secret put`
Deployment notes:
- Set secrets with wrangler secret put for CLOUDFLARE_API_TOKEN, ZONE_ID, MAILGUN_API_KEY, MAILGUN_DOMAIN, EMAIL_TO, EMAIL_FROM. Optionally set SPF_ASSET_PATH as env if different.
- Ensure CLOUDFLARE_API_TOKEN has DNS:Edit for the zone.
- Verify Mailgun domain and from address are authorized.
- Test in staging before production.
If you want, I can produce a minimized wrangler.toml with example bindings and instructions for creating secrets.
wrangler.toml (minimized)
name = "flatten-spf-worker"
main = "src/worker.js"
compatibility_date = "2026-05-06"
# scheduled cron
[triggers]
crons = ["*/5 * * * *"]
Instructions (minimized)
-
Install WranglerCommands to create secrets and publish (use your shell):
-
Login:
- wrangler login
-
Add secrets:
- wrangler secret put CLOUDFLARE_API_TOKEN
- wrangler secret put ZONE_ID
- wrangler secret put MAILGUN_API_KEY
- wrangler secret put MAILGUN_DOMAIN
- wrangler secret put EMAIL_TO
- wrangler secret put EMAIL_FROM
- (Optional) wrangler secret put SPF_ASSET_PATH
-
Publish:
- wrangler publish --env production
Instructions to host code in cloudflare artifact /git repo and set up the worker to pull the code from that repo
Short instructions:
- Create a Git repo
- Put project files (src/worker.js, wrangler.toml) in repo and commit.
- Create a Cloudflare Pages / Workers Git integration (Artifact/Git)
- In Cloudflare dashboard, go to Workers (or Pages with Workers integration) → Create a project → choose "Connect to Git" or "Deploy from Git" and connect your Git provider (GitHub/GitLab/Bitbucket).
- Select the repository and branch you committed to3. Configure build & artifact settings
- Set build command: none needed (worker is single-file). If using a build step (webpack, esbuild), provide the build command and output directory.
- Set the entry point to src/worker.js or the built artifact path.
- Set compatibility_date to 2026-05-06 in wrangler.toml or build settings.
- Environment variables & secrets
- In the Cloudflare project settings, add secrets (bind as Worker secrets):
- CLOUDFLARE_API_TOKEN
- ZONE_ID
- MAILGUN_API_KEY
- MAILGUN_DOMAIN
- EMAIL_TO
- EMAIL_FROM
- (Optional) SPF_ASSET_PATH
- Or add them via CLI: wrangler secret put (for the connected account/repo).
- Configure schedule trigger
- In wrangler.toml include: [triggers] crons = ["*/5 * * * *"]
- Or in dashboard schedule section set cron expression "*/5 * * * *".
- Connect deployment pipeline
- Enable automatic deployments on push for the selected branch in the Cloudflare Git integration settings.
- Optionally set protected branch rules in your Git provider to control releases.
- First deployment & verification
- Push to the connected branch.
- Confirm build/deploy succeeded in Cloudflare dashboard.
- Check Worker logs (Live Logs / Logs) for successful runs.
- Verify the SPF TXT was read/flattened and Mailgun notification received.
CLI alternative (manual deploy from repo)
- Install Wrangler v2+, login: wrangler login
- From repo root: wrangler publish
- Add secrets with: wrangler secret put
Notes
- Ensure the CLOUDFLARE_API_TOKEN has DNS:Edit permission for the ZONE_ID.
- Ensure MAILGUN_DOMAIN and EMAIL_FROM are authorized in Mailgun.
- If using Pages-based artifact deployment, configure the Pages build to output the worker artifact and set the worker to use that artifact.