Add DMARC creator tool

This commit is contained in:
Reimar 2026-01-14 13:50:56 +01:00
parent 2b2c60d6df
commit 75cf0abb1a
Signed by: Reimar
GPG Key ID: 93549FA07F0AE268
12 changed files with 329 additions and 43 deletions

46
assets/scripts/creator.js Normal file
View File

@ -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 = `<summary>${Tool.categories[category]}</summary>`;
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 += `
<label for="${field.key}">${field.displayName}</label>
<p class="description">${field.description ?? ""}</p>
${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;
}

View File

@ -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;
}
}

View File

@ -21,4 +21,16 @@ export class DmarcUriListField extends Field {
return true;
}
getInputHtml() {
return `<input id="${this.id}" type="email" name="${this.key}" placeholder="mail@example.com">`;
}
getInputValue() {
if (!document.getElementById(this.id).value) {
return null;
}
return "mailto:" + document.getElementById(this.id).value;
}
}

View File

@ -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 `<select id="${this.id}" name="${this.key}" ${this.isRequired ? "required" : ""}>` +
(this.isRequired || this.defaultValue ? "" : `<option value="" selected>&lt;not set&gt;</option>`) +
this.values.map((value, i) =>
`<option value="${value}" ${this.defaultValue === value ? "selected" : ""}>
${this.optionLabels[i] + (this.defaultValue === value ? " (Default)" : "")}
</option>`
) +
`</select>`;
}
getInputValue() {
return document.getElementById(this.id).value;
}
options(options) {
this.optionLabels = options;
return this;
}
}

View File

@ -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;

View File

@ -21,4 +21,12 @@ export class IntField extends Field {
return true;
}
getInputHtml() {
return `<input id="${this.id}" type="number" name="${this.key}" min="${this.min}" max="${this.max}" ${this.isRequired ? "required" : ""} placeholder="${this.defaultValue ?? ""}">`;
}
getInputValue() {
return document.getElementById(this.id).value;
}
}

View File

@ -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("; ");
}
}

View File

@ -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}`)

View File

@ -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;
}

View File

@ -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,<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 9L12 15L18 9" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"/></svg>');
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;
}

40
dmarc-creator/index.html Normal file
View File

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>DMARC Record Creator - Generate DMARC DNS Records</title>
<link rel="stylesheet" href="/assets/styles/main.css">
<script type="module" src="/assets/scripts/creator.js"></script>
</head>
<body>
<h1>DMARC Record Creator</h1>
<label for="record">DNS Record</label><br>
<input id="record" type="text" disabled>
<main>
<h2>Create a DMARC DNS Record</h2>
<p>Customize the options below to generate a DMARC record, which will be shown in the input field above.</p>
<form id="form"></form>
<hr>
<p>
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.
</p>
<p>
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.
</p>
<center>
<h3>More tools:</h3>
<a href="/dmarc-validator">DMARC Validator Tool</a>
</center>
</main>
</body>
</html>

View File

@ -2,19 +2,18 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>DMARC Validator</title>
<title>DMARC Record Validator - Validate DMARC DNS Records</title>
<link rel="stylesheet" href="/assets/styles/main.css">
<script type="module" src="/assets/scripts/validator.js"></script>
</head>
<body>
<h1>DMARC Validator</h1>
<h1>DMARC Record Validator</h1>
<label for="input">DNS Record</label><br>
<input id="input" type="text" placeholder="v=DMARC1; ..." autocapitalize="off" spellcheck="false">
<label for="record">DNS Record</label><br>
<input id="record" type="text" placeholder="v=DMARC1; ..." autocapitalize="off" spellcheck="false">
<main>
<h2 style="text-align: center;">Paste a DMARC DNS record into the input field to check if it is valid.</h2>
<h2>Paste a DMARC DNS record into the input field to check if it is valid.</h2>
<p id="error" class="validation-result">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path fill="currentColor" d="M320 576C461.4 576 576 461.4 576 320C576 178.6 461.4 64 320 64C178.6 64 64 178.6 64 320C64 461.4 178.6 576 320 576zM231 231C240.4 221.6 255.6 221.6 264.9 231L319.9 286L374.9 231C384.3 221.6 399.5 221.6 408.8 231C418.1 240.4 418.2 255.6 408.8 264.9L353.8 319.9L408.8 374.9C418.2 384.3 418.2 399.5 408.8 408.8C399.4 418.1 384.2 418.2 374.9 408.8L319.9 353.8L264.9 408.8C255.5 418.2 240.3 418.2 231 408.8C221.7 399.4 221.6 384.2 231 374.9L286 319.9L231 264.9C221.6 255.5 221.6 240.3 231 231z"/></svg>
@ -40,20 +39,20 @@
</p>
<p>
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.
</p>
<p>
The most important fields are:
The most important tags are:
</p>
<ul>
<li>
<b>p</b>: Tells what to do with emails that fail validation. Values can be "none" (do nothing),
"quarantine" (treat email as suspicious - e.g. place it in the spam folder) or
"reject" (outright discard the email upon receival). This field is required and must be placed after
the "v=DMARC1" field.
"reject" (outright discard the email upon receival). This tag is required and must be placed after
the "v=DMARC1" tag.
</li>
<li>
@ -71,6 +70,11 @@
A full list of rules can be found on the
<a href="https://datatracker.ietf.org/doc/html/rfc7489#section-6.3" target="_blank">DMARC specification page</a>.
</p>
<center>
<h3>More tools:</h3>
<a href="/dmarc-creator">DMARC Creator Tool</a>
</center>
</main>
</body>
</html>