From 5b8a3d9266c1a57befa9194b5b21735ad5e85ae1 Mon Sep 17 00:00:00 2001 From: Reimar Date: Thu, 15 Jan 2026 13:41:59 +0100 Subject: [PATCH] Implement SPF creator tool --- assets/scripts/Field.js | 13 ++++++ assets/scripts/records/SpfRecord.js | 42 ++++++++++++++++++ assets/scripts/records/TagListRecord.js | 6 ++- assets/scripts/spf/DomainMechanism.js | 10 ++--- assets/scripts/spf/IPv4Mechanism.js | 2 +- assets/scripts/spf/IPv6Mechanism.js | 2 + assets/scripts/spf/Mechanism.js | 27 +++++++++++ assets/scripts/spf/Modifier.js | 14 +++++- assets/scripts/spf/Term.js | 6 +++ assets/scripts/spf/VersionTerm.js | 4 ++ assets/scripts/spf/utils.js | 6 +++ assets/scripts/tags/Tag.js | 7 --- assets/scripts/{ => ui}/creator.js | 15 +++---- assets/scripts/{ => ui}/validator.js | 4 +- dmarc-creator/index.html | 2 +- dmarc-validator/index.html | 2 +- spf-creator/index.html | 59 +++++++++++++++++++++++++ spf-validator/index.html | 2 +- 18 files changed, 191 insertions(+), 32 deletions(-) create mode 100644 assets/scripts/spf/utils.js rename assets/scripts/{ => ui}/creator.js (75%) rename assets/scripts/{ => ui}/validator.js (90%) create mode 100644 spf-creator/index.html diff --git a/assets/scripts/Field.js b/assets/scripts/Field.js index df80f1a..3a3551e 100644 --- a/assets/scripts/Field.js +++ b/assets/scripts/Field.js @@ -5,9 +5,12 @@ export class Field { displayName = null; description = null; + categoryName = null; + isHidden = false; constructor(key) { this.key = key; + this.id = "field-" + key; } // Virtual methods @@ -31,4 +34,14 @@ export class Field { this.description = description; return this; } + + category(category) { + this.categoryName = category; + return this; + } + + hidden() { + this.isHidden = true; + return this; + } } diff --git a/assets/scripts/records/SpfRecord.js b/assets/scripts/records/SpfRecord.js index a152c31..332cc9d 100644 --- a/assets/scripts/records/SpfRecord.js +++ b/assets/scripts/records/SpfRecord.js @@ -14,44 +14,73 @@ export class SpfRecord { .pos(0), new DomainMechanism("include") + .label("Include") + .desc("Also apply the SPF records from these domains") .multiple() .pos(1), new DomainMechanism("a") + .label("Domains") + .desc("Select the IP address from these domains") + .value(ValueRequirement.OPTIONAL) .multiple() .pos(1), new DomainMechanism("mx") + .label("MX Records") + .desc("Select the IP addresses from the MX records of these domains (or current domain if none specified)") + .value(ValueRequirement.OPTIONAL) .multiple() .pos(2), new DomainMechanism("ptr") + .hidden() + .value(ValueRequirement.OPTIONAL) .multiple() .pos(2), new IPv4Mechanism("ipv4") + .label("IPv4 addresses") + .desc("Select these IP addresses") .multiple() .pos(2), new IPv6Mechanism("ipv6") + .label("IPv6 addresses") + .desc("Select these IP addresses") .multiple() .pos(2), new DomainMechanism("exists") + .label("Exists") + .desc("Apply only if this domain exists (can be used with macro expansions)") + .category("advanced") .multiple() .pos(2), new Mechanism("all") + .label("All others") + .desc("How to treat the rest of the IP addresses") .value(ValueRequirement.PROHIBITED) .pos(3), new Modifier("redirect") + .label("Redirect") + .desc("Redirect to the SPF record of this domain if no IP addresses matched") + .category("advanced") .pos(4), new Modifier("exp") + .label("Explanation") + .desc("Points to a domain whose TXT record contains an error message if validation fails. Macros can be used here") + .category("advanced") .pos(4), ]; + static categories = { + "advanced": "Advanced", + }; + constructor(text) { this.text = text; } @@ -121,4 +150,17 @@ export class SpfRecord { lastPos = term.position; } } + + static fieldsToString() { + const tokens = this.fields + .filter(field => !field.isHidden) + .filter(field => field.valueRequirement === ValueRequirement.REQUIRED ? field.getInputValue() : field.getInputQualifier()) + .map(field => + field.getInputQualifier() + + field.key + + (field.getInputValue() ? field.separator + field.getInputValue() : "") + ); + + return tokens.join(" "); + } } diff --git a/assets/scripts/records/TagListRecord.js b/assets/scripts/records/TagListRecord.js index 4706cce..3a2b073 100644 --- a/assets/scripts/records/TagListRecord.js +++ b/assets/scripts/records/TagListRecord.js @@ -62,11 +62,13 @@ export class TagListRecord { return true; } - fieldsToString() { - let tokens = this.constructor.fields + static fieldsToString() { + const tokens = this.fields + .filter(field => !field.isHidden) .filter(field => !field.defaultValue || field.getInputValue() !== field.defaultValue) .filter(field => field.getInputValue()) .map(field => field.key + "=" + field.getInputValue()); + return tokens.join("; "); } } diff --git a/assets/scripts/spf/DomainMechanism.js b/assets/scripts/spf/DomainMechanism.js index 08f0b5f..124086a 100644 --- a/assets/scripts/spf/DomainMechanism.js +++ b/assets/scripts/spf/DomainMechanism.js @@ -1,20 +1,16 @@ import { ValidationError } from "../ValidationError.js"; import { Mechanism } from "./Mechanism.js"; +import { validateSpfDomain } from "./utils.js"; export class DomainMechanism extends Mechanism { - separator = ":"; + placeholder = "example.com"; constructor(key) { super(key); } validate(value) { - // https://www.rfc-editor.org/rfc/rfc7208#section-7.1 - const valid = value - .split(".") - .every(segment => segment.match(/^([^%]|%_|%%|%-|%\{[slodiphcrtv]\d*r?[-.+,\/_=]*})+$/)); - - if (!valid) throw new ValidationError(`Value for "${this.key}" is not a valid domain name`); + if (!validateSpfDomain(value)) throw new ValidationError(`Value for "${this.key}" is not a valid domain name`); return true; } diff --git a/assets/scripts/spf/IPv4Mechanism.js b/assets/scripts/spf/IPv4Mechanism.js index e6c3880..3c51d72 100644 --- a/assets/scripts/spf/IPv4Mechanism.js +++ b/assets/scripts/spf/IPv4Mechanism.js @@ -2,7 +2,7 @@ import { ValidationError } from "../ValidationError.js"; import { Mechanism } from "./Mechanism.js"; export class IPv4Mechanism extends Mechanism { - separator = ":"; + placeholder = "0.0.0.0"; constructor(key) { super(key); diff --git a/assets/scripts/spf/IPv6Mechanism.js b/assets/scripts/spf/IPv6Mechanism.js index a6c370a..b794a7d 100644 --- a/assets/scripts/spf/IPv6Mechanism.js +++ b/assets/scripts/spf/IPv6Mechanism.js @@ -1,6 +1,8 @@ import { Mechanism } from "./Mechanism.js"; export class IPv6Mechanism extends Mechanism { + placeholder = "2001:db8::1"; + constructor(key) { super(key); } diff --git a/assets/scripts/spf/Mechanism.js b/assets/scripts/spf/Mechanism.js index e71ae53..4ebd487 100644 --- a/assets/scripts/spf/Mechanism.js +++ b/assets/scripts/spf/Mechanism.js @@ -1,9 +1,36 @@ import { Term } from "./Term.js"; +import { ValueRequirement } from "./ValueRequirement.js"; export class Mechanism extends Term { separator = ":"; + placeholder = null; constructor(key) { super(key); } + + getInputQualifier() { + return document.getElementById(this.id + "-qualifier").value; + } + + getInputValue() { + return document.getElementById(this.id + "-value")?.value; + } + + getInputHtml() { + const noValue = this.valueRequirement === ValueRequirement.PROHIBITED; + const placeholder = this.placeholder + + (this.valueRequirement === ValueRequirement.OPTIONAL ? " (Optional)" : ""); + + return ` + + ${noValue ? "" : ``} + `; + } } diff --git a/assets/scripts/spf/Modifier.js b/assets/scripts/spf/Modifier.js index 639e072..890f0c3 100644 --- a/assets/scripts/spf/Modifier.js +++ b/assets/scripts/spf/Modifier.js @@ -1,5 +1,6 @@ import { Term } from "./Term.js"; import { ValidationError } from "../ValidationError.js"; +import { validateSpfDomain } from "./utils.js"; export class Modifier extends Term { separator = "="; @@ -8,8 +9,17 @@ export class Modifier extends Term { super(key); } - validate() { - // TODO validate + validate(value) { + if (!validateSpfDomain(value)) throw new ValidationError(`Value for "${this.key}" is not a valid domain name`); + return true; } + + getInputValue() { + return document.getElementById(this.id).value; + } + + getInputHtml() { + return ``; + } } diff --git a/assets/scripts/spf/Term.js b/assets/scripts/spf/Term.js index a4a1db0..7777b15 100644 --- a/assets/scripts/spf/Term.js +++ b/assets/scripts/spf/Term.js @@ -12,6 +12,12 @@ export class Term extends Field { super(key) } + // Virtual methods + + getInputQualifier() { + return ""; + } + // Builder methods required() { diff --git a/assets/scripts/spf/VersionTerm.js b/assets/scripts/spf/VersionTerm.js index 039220a..0e1d134 100644 --- a/assets/scripts/spf/VersionTerm.js +++ b/assets/scripts/spf/VersionTerm.js @@ -14,4 +14,8 @@ export class VersionTerm extends Term { return true; } + + getInputValue() { + return this.version; + } } diff --git a/assets/scripts/spf/utils.js b/assets/scripts/spf/utils.js new file mode 100644 index 0000000..9feb269 --- /dev/null +++ b/assets/scripts/spf/utils.js @@ -0,0 +1,6 @@ +// https://www.rfc-editor.org/rfc/rfc7208#section-7.1 +export function validateSpfDomain(domain) { + return domain + .split(".") + .every(segment => segment.match(/^([^%]|%_|%%|%-|%\{[slodiphcrtv]\d*r?[-.+,\/_=]*})+$/)); +} diff --git a/assets/scripts/tags/Tag.js b/assets/scripts/tags/Tag.js index 9214b94..cce9899 100644 --- a/assets/scripts/tags/Tag.js +++ b/assets/scripts/tags/Tag.js @@ -4,12 +4,10 @@ import { Field } from "../Field.js"; export class Tag extends Field { isRequired = false; defaultValue = null; - categoryName = null; position = null; constructor(key) { super(key); - this.id = "tag-" + key; } // Virtual methods @@ -30,11 +28,6 @@ export class Tag extends Field { return this; } - category(category) { - this.categoryName = category; - return this; - } - pos(i) { this.position = i; return this; diff --git a/assets/scripts/creator.js b/assets/scripts/ui/creator.js similarity index 75% rename from assets/scripts/creator.js rename to assets/scripts/ui/creator.js index b7ef1b3..723e688 100644 --- a/assets/scripts/creator.js +++ b/assets/scripts/ui/creator.js @@ -1,7 +1,9 @@ -import { DmarcRecord } from "./records/DmarcRecord.js"; +import { DmarcRecord } from "../records/DmarcRecord.js"; +import { SpfRecord } from "../records/SpfRecord.js"; const records = { "/dmarc-creator": DmarcRecord, + "/spf-creator": SpfRecord, }; const Record = records[location.pathname]; @@ -21,7 +23,7 @@ for (const category of categories) { function addFields(elem, fields) { for (const field of fields) { - if (!field.getInputHtml()) continue; + if (field.isHidden || !field.getInputHtml()) continue; elem.innerHTML += ` @@ -31,14 +33,11 @@ function addFields(elem, fields) { } } -document.getElementById("form").onchange = () => generate(); - +document.getElementById("form").onchange = generate; generate(); -function generate() { - const record = new Record(); - - document.getElementById("record").value = record.fieldsToString(); +function generate(event) { + document.getElementById("record").value = Record.fieldsToString(); } document.getElementById("record").onclick = (e) => { diff --git a/assets/scripts/validator.js b/assets/scripts/ui/validator.js similarity index 90% rename from assets/scripts/validator.js rename to assets/scripts/ui/validator.js index 24757b5..f293867 100644 --- a/assets/scripts/validator.js +++ b/assets/scripts/ui/validator.js @@ -1,5 +1,5 @@ -import { DmarcRecord } from "./records/DmarcRecord.js"; -import { SpfRecord } from "./records/SpfRecord.js"; +import { DmarcRecord } from "../records/DmarcRecord.js"; +import { SpfRecord } from "../records/SpfRecord.js"; const records = { "/dmarc-validator": DmarcRecord, diff --git a/dmarc-creator/index.html b/dmarc-creator/index.html index 4d87a9b..d7be9c2 100644 --- a/dmarc-creator/index.html +++ b/dmarc-creator/index.html @@ -4,7 +4,7 @@ DMARC Record Creator - Generate DMARC DNS Records - +

DMARC Record Creator

diff --git a/dmarc-validator/index.html b/dmarc-validator/index.html index f5d9f20..cf6d504 100644 --- a/dmarc-validator/index.html +++ b/dmarc-validator/index.html @@ -4,7 +4,7 @@ DMARC Record Validator - Validate DMARC DNS Records - +

DMARC Record Validator

diff --git a/spf-creator/index.html b/spf-creator/index.html new file mode 100644 index 0000000..8107615 --- /dev/null +++ b/spf-creator/index.html @@ -0,0 +1,59 @@ + + + + + SPF Record Creator - Generate SPF DNS Records + + + + +

SPF Record Creator

+ +
+ + +
+

Create an SPF DNS Record

+ +

+ Customize the options below to generate an SPF record, which will be shown in the input field above. + The qualifiers that can be used are: +

+ +

+ Pass (Default): Selected IP addresses are authorized to send emails from this domain
+ Fail: Selected IP addresses are NOT authorized to send emails from this domain
+ Soft fail: Selected IP addresses might not be authorized to send emails from this domain (actual behavior differs)
+ Neutral: Do not explicitly state whether the IP addresses are authorized or not (can be used for overriding other qualifiers) +

+ +
+ +
+ +

+ This tool allows you to create SPF DNS records, which are used to authorize emails, and are necessary + to be able to send emails to most providers, such as Gmail, Outlook and Yahoo Mail. +

+ +

+ It works by specifying which IP addresses are authorized or not authorized to send emails. + You can specify IP addresses from a domain, from your MX records, or explicitly. + When specifying IP addresses, you then say whether these should pass or fail the validation. +

+ +

+ For advanced usage, domain fields may contain macros. These start with a percentage sign and will expand + to a dynamic value. For example, %{d} expands to the current domain and %{i} to the + current IP address. See + the SPF specification + for a list of macros you can use. +

+ +
+

More tools:

+ SPF Validator Tool +
+
+ + diff --git a/spf-validator/index.html b/spf-validator/index.html index 63a814e..9342db9 100644 --- a/spf-validator/index.html +++ b/spf-validator/index.html @@ -4,7 +4,7 @@ SPF Record Validator - Validate SPF DNS Records - +

SPF Record Validator