From 75cf0abb1a14d1793a7064fb24a3f6a83aa10412 Mon Sep 17 00:00:00 2001 From: Reimar Date: Wed, 14 Jan 2026 13:50:56 +0100 Subject: [PATCH] Add DMARC creator tool --- assets/scripts/creator.js | 46 ++++++++++ assets/scripts/fields/ConstantField.js | 4 + assets/scripts/fields/DmarcUriListField.js | 12 +++ assets/scripts/fields/EnumField.js | 21 +++++ assets/scripts/fields/Field.js | 31 +++++++ assets/scripts/fields/IntField.js | 8 ++ assets/scripts/tools/DmarcTool.js | 99 ++++++++++++++++++---- assets/scripts/tools/DnsTool.js | 6 +- assets/scripts/validator.js | 12 +-- assets/styles/main.css | 69 +++++++++++++-- dmarc-creator/index.html | 40 +++++++++ dmarc-validator/index.html | 24 +++--- 12 files changed, 329 insertions(+), 43 deletions(-) create mode 100644 assets/scripts/creator.js create mode 100644 dmarc-creator/index.html diff --git a/assets/scripts/creator.js b/assets/scripts/creator.js new file mode 100644 index 0000000..97150ff --- /dev/null +++ b/assets/scripts/creator.js @@ -0,0 +1,46 @@ +import { DmarcTool } from "./tools/DmarcTool.js"; + +const tools = { + "/dmarc-creator": DmarcTool, +}; + +const Tool = tools[location.pathname]; + +addFields(document.getElementById("form"), Tool.fields.filter(field => !field.categoryName)); + +const categories = Tool.fields.map(field => field.categoryName).filter(isUnique).filter(val => val); + +for (const category of categories) { + const details = document.createElement("details"); + details.innerHTML = `${Tool.categories[category]}`; + + document.getElementById("form").appendChild(details); + + addFields(details, Tool.fields.filter(field => field.categoryName === category)); +} + +function addFields(elem, fields) { + for (const field of fields) { + if (!field.getInputHtml()) continue; + + elem.innerHTML += ` + +

${field.description ?? ""}

+ ${field.getInputHtml()} + `; + } +} + +document.getElementById("form").onchange = () => generate(); + +generate(); + +function generate() { + const tool = new Tool(); + + document.getElementById("record").value = tool.fieldsToString(); +} + +function isUnique(value, index, array) { + return array.indexOf(value) === index; +} diff --git a/assets/scripts/fields/ConstantField.js b/assets/scripts/fields/ConstantField.js index 1c069f1..50bd693 100644 --- a/assets/scripts/fields/ConstantField.js +++ b/assets/scripts/fields/ConstantField.js @@ -10,4 +10,8 @@ export class ConstantField extends Field { validate(value) { if (this.value !== value) throw new ValidationError(`Field ${this.key} must be "${this.value}"`) } + + getInputValue() { + return this.value; + } } diff --git a/assets/scripts/fields/DmarcUriListField.js b/assets/scripts/fields/DmarcUriListField.js index 21ac4a4..b90552d 100644 --- a/assets/scripts/fields/DmarcUriListField.js +++ b/assets/scripts/fields/DmarcUriListField.js @@ -21,4 +21,16 @@ export class DmarcUriListField extends Field { return true; } + + getInputHtml() { + return ``; + } + + getInputValue() { + if (!document.getElementById(this.id).value) { + return null; + } + + return "mailto:" + document.getElementById(this.id).value; + } } diff --git a/assets/scripts/fields/EnumField.js b/assets/scripts/fields/EnumField.js index f3d7b1a..55e64ef 100644 --- a/assets/scripts/fields/EnumField.js +++ b/assets/scripts/fields/EnumField.js @@ -5,6 +5,7 @@ export class EnumField extends Field { constructor(key, values) { super(key); this.values = values; + this.optionLabels = values; } validate(value) { @@ -13,4 +14,24 @@ export class EnumField extends Field { throw new ValidationError(`Invalid value for field "${this.key}" - must be one of: ${this.values.join(", ")}`); } + + getInputHtml() { + return ``; + } + + getInputValue() { + return document.getElementById(this.id).value; + } + + options(options) { + this.optionLabels = options; + return this; + } } diff --git a/assets/scripts/fields/Field.js b/assets/scripts/fields/Field.js index 44242c8..b54614f 100644 --- a/assets/scripts/fields/Field.js +++ b/assets/scripts/fields/Field.js @@ -1,17 +1,33 @@ export class Field { isRequired = false; defaultValue = null; + displayName = null; + description = null; + categoryName = null; requiredIndex = null; afterFieldName = null; constructor(key) { this.key = key; + this.id = "field-" + key; } + // Virtual methods + validate() { return true; } + getInputHtml() { + return null; + } + + getInputValue() { + return null; + } + + // Builder methods + required() { this.isRequired = true; return this; @@ -22,6 +38,21 @@ export class Field { return this; } + label(label) { + this.displayName = label; + return this; + } + + desc(description) { + this.description = description; + return this; + } + + category(category) { + this.categoryName = category; + return this; + } + atIndex(i) { this.requiredIndex = i; return this; diff --git a/assets/scripts/fields/IntField.js b/assets/scripts/fields/IntField.js index 0c08fe7..dddaa68 100644 --- a/assets/scripts/fields/IntField.js +++ b/assets/scripts/fields/IntField.js @@ -21,4 +21,12 @@ export class IntField extends Field { return true; } + + getInputHtml() { + return ``; + } + + getInputValue() { + return document.getElementById(this.id).value; + } } diff --git a/assets/scripts/tools/DmarcTool.js b/assets/scripts/tools/DmarcTool.js index 3e01920..c8cbd71 100644 --- a/assets/scripts/tools/DmarcTool.js +++ b/assets/scripts/tools/DmarcTool.js @@ -7,35 +7,94 @@ import { DnsTool } from "./DnsTool.js"; import { ValidationError } from "../ValidationError.js"; export class DmarcTool extends DnsTool { - fields = [ - new ConstantField("v", "DMARC1").required().atIndex(0), - new EnumField("p", ["none", "quarantine", "reject"]).required().atIndex(1).after("v"), - new EnumField("sp", ["none", "quarantine", "reject"]), - new IntField("pct", 0, 100).default(100), - new EnumField("adkim", ["r", "s"]).default("r"), - new EnumField("aspf", ["r", "s"]).default("r"), - new EnumField("fo", ["0", "1", "d", "s"]).default("0"), - new Field("rf").default("afrf"), - new IntField("ri", 0, 2 ** 32).default(86400), - new DmarcUriListField("rua"), - new DmarcUriListField("ruf"), + static fields = [ + new ConstantField("v", "DMARC1") + .required() + .atIndex(0), + + new EnumField("p", ["none", "quarantine", "reject"]) + .label("Mail Receiver policy") + .desc("How to handle failed validations. The email may be quarantined (usually means sent to spam) or rejected") + .options(["None", "Quarantine", "Reject"]) + .required() + .atIndex(1) + .after("v"), + + new EnumField("adkim", ["r", "s"]) + .label("DKIM") + .desc("How strictly to handle DKIM validation") + .options(["Relaxed", "Strict"]) + .default("r"), + + new EnumField("aspf", ["r", "s"]) + .label("SPF") + .desc("How strictly to handle SPF validation") + .options(["Relaxed", "Strict"]) + .default("r"), + + new EnumField("sp", ["none", "quarantine", "reject"]) + .label("Mail Receiver policy (for subdomains)") + .desc("Same as Mail Receiver policy, but applies only to subdomains. If not set, Mail Receiver policy applies to both top-level domain and subdomains") + .category("advanced") + .options(["None", "Quarantine", "Reject"]), + + new IntField("pct", 0, 100) + .label("Percentage") + .desc("Percentage of emails to apply DMARC validation on. Useful for split-testing and continuous rollout") + .category("advanced") + .default(100), + + new DmarcUriListField("ruf") + .label("Send failure reports to") + .desc("When DMARC validation fails, reports are sent to this email") + .category("failure-reporting"), + + new EnumField("fo", ["0", "1", "d", "s"]) + .label("Failure reporting options") + .desc("Define how reports will be generated") + .category("failure-reporting") + .options([ + "Generate DMARC failure report if any fail", + "Generate DMARC failure report if all fail", + "Generate DKIM failure report", + "Generate SPF failure report", + ]) + .default("0"), + + new DmarcUriListField("rua") + .label("Send aggregate feedback to") + .desc("Aggregate reports will be sent to this email, if defined") + .category("failure-reporting"), + + new IntField("ri", 0, 2 ** 32) + .label("Aggregate report interval") + .desc("Interval (in seconds) between aggregate reports") + .category("failure-reporting") + .default(86400), + + new Field("rf").default("afrf"), // Other values not supported ]; + static categories = { + "advanced": "Advanced", + "failure-reporting": "Failure reporting", + }; + constructor(text) { super(text); } tokenize() { - return this.text.split(/;\s*/); + return this.text + .replace(/;\s+$/, "") + .split(/;\s*/); } getKeyValues() { const result = []; for (const token of this.tokenize()) { - if (token === "") continue; - - const [key, value] = token.split("="); + const [key, value] = token.split(/\s*=\s*/); if (!value) { throw new ValidationError(`Field "${key}" is missing a value`); @@ -46,4 +105,12 @@ export class DmarcTool extends DnsTool { return result; } + + fieldsToString() { + let tokens = this.constructor.fields + .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/tools/DnsTool.js b/assets/scripts/tools/DnsTool.js index 81f1e98..f198c24 100644 --- a/assets/scripts/tools/DnsTool.js +++ b/assets/scripts/tools/DnsTool.js @@ -1,7 +1,7 @@ import { ValidationError } from "../ValidationError.js"; export class DnsTool { - fields = []; + static fields = []; constructor(text) { this.text = text; @@ -15,7 +15,7 @@ export class DnsTool { const values = this.getKeyValues(); // Validate field order - for (const field of this.fields) { + for (const field of this.constructor.fields) { const valueIdx = values.findIndex(v => v.key === field.key); if (field.isRequired && valueIdx === -1) { @@ -32,7 +32,7 @@ export class DnsTool { // Validate field values for (let i = 0; i < values.length; i++) { const input = values[i]; - const field = this.fields.find(f => f.key === input.key); + const field = this.constructor.fields.find(f => f.key === input.key); if (!field) { throw new ValidationError(`Unknown field: ${input.key}`) diff --git a/assets/scripts/validator.js b/assets/scripts/validator.js index 1090592..0a1e7e6 100644 --- a/assets/scripts/validator.js +++ b/assets/scripts/validator.js @@ -6,14 +6,14 @@ const tools = { const Tool = tools[location.pathname]; -document.getElementById("input").oninput = event => validate(event.target.value); +document.getElementById("record").oninput = event => validate(event.target.value); -if (document.getElementById("input").value !== "") { - validate(document.getElementById("input").value); +if (document.getElementById("record").value !== "") { + validate(document.getElementById("record").value); } function validate(value) { - document.getElementById("input").classList.remove("valid", "invalid"); + document.getElementById("record").classList.remove("valid", "invalid"); document.getElementById("error").style.display = "none" document.getElementById("success").style.display = "none"; @@ -29,10 +29,10 @@ function validate(value) { try { tool.validate(); - document.getElementById("input").classList.add("valid"); + document.getElementById("record").classList.add("valid"); document.getElementById("success").style.display = "flex"; } catch (e) { - document.getElementById("input").classList.add("invalid"); + document.getElementById("record").classList.add("invalid"); document.getElementById("error").style.display = "flex"; document.getElementById("error-message").innerText = e.message; } diff --git a/assets/styles/main.css b/assets/styles/main.css index ae2fc63..8b7f7c8 100644 --- a/assets/styles/main.css +++ b/assets/styles/main.css @@ -26,7 +26,7 @@ body { margin: auto; } -h1 { +h1, h2 { text-align: center; } @@ -35,7 +35,7 @@ label { font-size: 0.8rem; } -#input { +#record { font-family: "JetBrains Mono", monospace; border: 2px solid black; font-size: 1rem; @@ -45,20 +45,24 @@ label { transition: all 200ms; } -#input:hover { +#record:disabled { + background-color: #F9F9F9; +} + +#record:not(:disabled):hover { box-shadow: 0 2px 2px rgba(0, 0, 0, 0.1); } -#input:focus { +#record:focus { outline: none; - box-shadow: 0 4px 4px rgba(0, 0, 0, 0.1); + box-shadow: 0 4px 4px rgba(0, 0, 0, 0.1) !important; } -#input.valid { +#record.valid { background-color: #C8E6C9; } -#input.invalid { +#record.invalid { background-color: #FFCDD2; } @@ -66,7 +70,7 @@ main { background-color: white; border: 1px solid #BDBDBD; padding: 1rem 2rem; - margin-top: 2rem; + margin: 2rem auto; color: #424242; font-size: 0.8rem; } @@ -75,6 +79,12 @@ a { color: #039BE5; } +hr { + border: none; + border-bottom: 1px solid #BDBDBD; + margin: 2rem auto; +} + li:not(:first-of-type) { margin-top: 0.75rem; } @@ -100,3 +110,46 @@ li:not(:first-of-type) { #result-placeholder { display: flex; } + +form label { + color: black; + font-weight: bold; + display: block; + margin-top: 1.5rem; + margin-bottom: 0.2rem; +} + +form input { + border: 1px solid #BDBDBD; + padding: 0.5rem 1rem; + transition: border 200ms; +} + +form input:focus { + border-color: black; + outline: none; +} + +form select { + appearance: none; + padding: 0.5rem 2rem 0.5rem 1rem; + background-color: #F9F9F9; + border: 1px solid #BDBDBD; + background-image: url('data:image/svg+xml;utf8,'); + background-repeat: no-repeat; + background-position: right 6px center; +} + +form .description { + margin: 0 0 0.5rem 0; +} + +summary { + padding-top: 1rem; + transition: color 200ms; + cursor: pointer; +} + +summary:hover { + color: black; +} diff --git a/dmarc-creator/index.html b/dmarc-creator/index.html new file mode 100644 index 0000000..73d533d --- /dev/null +++ b/dmarc-creator/index.html @@ -0,0 +1,40 @@ + + + + + DMARC Record Creator - Generate DMARC DNS Records + + + + +

DMARC Record Creator

+ +
+ + +
+

Create a DMARC DNS Record

+ +

Customize the options below to generate a DMARC record, which will be shown in the input field above.

+ +
+ +
+ +

+ This tool allows you to create DMARC DNS records, which can be used when you are hosting an email-server + and want to provide an extra layer of security, so other email providers will trust your emails. +

+ +

+ Select the options you want and copy the text. The DNS record should be a TXT record in the _dmarc + subdomain (e.g. _dmarc.example.com) with the content being the text above. +

+ +
+

More tools:

+ DMARC Validator Tool +
+
+ + diff --git a/dmarc-validator/index.html b/dmarc-validator/index.html index efad72e..cfd81fd 100644 --- a/dmarc-validator/index.html +++ b/dmarc-validator/index.html @@ -2,19 +2,18 @@ - DMARC Validator + DMARC Record Validator - Validate DMARC DNS Records -

DMARC Validator

+

DMARC Record Validator

-
- +
+
- -

Paste a DMARC DNS record into the input field to check if it is valid.

+

Paste a DMARC DNS record into the input field to check if it is valid.

@@ -40,20 +39,20 @@

- The content is a list of fields, separated by a semicolon. It must always start with "v=DMARC1" so the + The content is a list of tags, separated by a semicolon. It must always start with "v=DMARC1" so the DMARC record can be easily identified.

- The most important fields are: + The most important tags are: