Compare commits

..

3 Commits

Author SHA1 Message Date
0d41b2b813
Add DKIM record creator 2026-01-19 12:41:02 +01:00
7183b21d24
Improve SPF validation 2026-01-19 09:51:04 +01:00
dbde3a5834
Implement fields having multiple items in creator 2026-01-19 09:39:51 +01:00
21 changed files with 369 additions and 52 deletions

View File

@ -10,6 +10,8 @@ export class Field {
categoryName = null; categoryName = null;
isHidden = false; isHidden = false;
isDisabled = false; isDisabled = false;
isRequired = false;
allowMultiple = false;
constructor(key) { constructor(key) {
this.key = key; this.key = key;
@ -31,6 +33,11 @@ export class Field {
// Builder methods // Builder methods
required() {
this.isRequired = true;
return this;
}
label(label) { label(label) {
this.displayName = label; this.displayName = label;
return this; return this;
@ -55,4 +62,9 @@ export class Field {
this.isDisabled = true; this.isDisabled = true;
return this; return this;
} }
multiple() {
this.allowMultiple = true;
return this;
}
} }

View File

@ -1,29 +1,66 @@
import { FieldInputItem } from "./FieldInputItem.js";
/** /**
* Represents the actual input element on the creator tool * Represents the actual input element on the creator tool
* A field may have multiple inputs if it allows multiple values * May contain multiple items if the field allows multiple values
*/ */
export class FieldInput { export class FieldInput {
constructor(field, parentElem) { constructor(field, parentElem) {
this.field = field; this.field = field;
this.id = "field-" + field.key; this.id = "field-" + field.key;
this.items = [];
this.parent = parentElem;
if (!field.isHidden) this.appendHtml(parentElem);
parentElem.innerHTML += `
<label for="${field.key}">${field.displayName}</label>
<p class="description">${field.description ?? ""}</p>
${field.getInputHtml(this.id)}
`;
} }
isValid() { appendHtml(parent) {
return this.field.isValidInput(this.id); const fieldContainer = document.createElement("div");
fieldContainer.className = "field-container";
const item = new FieldInputItem(this, fieldContainer);
if (!this.field.isHidden) {
parent.insertAdjacentHTML("beforeend", `
<label for="${this.field.key}">${this.field.displayName}</label>
<p class="description">${this.field.description ?? ""}</p>
`);
parent.appendChild(fieldContainer);
if (this.field.allowMultiple && this.items.length === 0) {
const addBtn = document.createElement("button");
addBtn.className = "text-button";
addBtn.type = "button";
addBtn.innerText = "+ Add";
addBtn.onclick = () => this.addItem(fieldContainer);
parent.appendChild(addBtn);
}
}
this.items.push(item);
this.updateItems();
} }
getValue() { updateItems() {
return this.field.getInputValue(this.id); for (const item of this.items) item.update();
} }
toString() { addItem(parent) {
return this.field.inputToString(this.id); const item = new FieldInputItem(this, parent);
this.items.push(item);
this.updateItems();
}
removeItem(id) {
const i = this.items.findIndex(item => item.id === id);
this.items[i].remove();
this.items.splice(i, 1);
this.updateItems();
this.parent.closest("form").onchange();
} }
} }

View File

@ -0,0 +1,59 @@
export class FieldInputItem {
container = null;
removeBtn = null;
constructor(input, parentElem) {
this.input = input;
this.field = input.field;
this.id = input.id + (input.field.allowMultiple ? "-" + Math.random().toString().slice(2, 5) : "");
this.appendHtml(parentElem);
}
appendHtml(parent) {
const container = document.createElement("div");
container.className = "input-container";
if (this.field.allowMultiple) {
const wrapper = document.createElement("div");
wrapper.innerHTML = this.field.getInputHtml(this.id);
container.appendChild(wrapper);
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "text-button";
removeBtn.innerHTML = "&times; Remove";
removeBtn.onclick = () => this.input.removeItem(this.id);
container.appendChild(removeBtn);
this.removeBtn = removeBtn;
} else {
container.innerHTML = this.field.getInputHtml(this.id);
}
parent.appendChild(container);
this.container = container;
}
update() {
if (this.removeBtn) {
this.removeBtn.style.display = this.input.items.length > 1 ? "inline-block" : "none";
}
}
remove() {
this.container.remove();
}
isValid() {
return this.field.isValidInput(this.id);
}
getValue() {
return this.field.getInputValue(this.id);
}
toString() {
return this.field.inputToString(this.id);
}
}

View File

@ -0,0 +1,55 @@
import { TagListRecord } from "./TagListRecord.js";
import { ConstantTag } from "../tags/ConstantTag.js";
import { EnumTag } from "../tags/EnumTag.js";
import { TextTag } from "../tags/TextTag.js";
import { FlagsTag } from "../tags/FlagsTag.js";
export class DkimRecord extends TagListRecord {
static fields = [
new ConstantTag("v", "DKIM1")
.required()
.hidden()
.pos(0),
new TextTag("p")
.label("Public key")
.desc("Base64-encoded public key data")
.required()
.pos(1),
new FlagsTag("t", ["y", "s"])
.label("Flags")
.desc("Optional extra options that can be enabled")
.options(["Test mode", "Require identical domain in i= and d= tags of DKIM signature (Recommended)"])
.default("")
.pos(1),
new EnumTag("h", ["sha1", "sha256"])
.label("Hash algorithms")
.desc("Which hash algorithms are allowed to be used. If not set, all are allowed")
.options(["SHA-1", "SHA-256"])
.category("advanced")
.pos(1),
new TextTag("n")
.label("Note")
.desc("Any extra comments for humans. Not parsed by machines")
.category("advanced")
.default("")
.pos(1),
new EnumTag("k", ["rsa"])
.disabled()
.default("rsa")
.pos(1),
new EnumTag("s", ["*", "email"])
.disabled()
.default("*")
.pos(1),
];
static categories = {
"advanced": "Advanced",
};
}

View File

@ -78,9 +78,10 @@ export class DmarcRecord extends TagListRecord {
.default(86400) .default(86400)
.pos(2), .pos(2),
new Tag("rf") new EnumTag("rf", ["afrf"])
.disabled() .disabled()
.default("afrf"), // Other values not supported .default("afrf")
.pos(2),
]; ];
static categories = { static categories = {

View File

@ -86,10 +86,10 @@ export class SpfRecord {
this.text = text; this.text = text;
} }
static createFromFieldInputs(fieldInputs) { static createFromFieldInputItems(items) {
const tokens = fieldInputs const tokens = items
.filter(input => !input.field.isDisabled && input.isValid()) .filter(item => !item.field.isDisabled && item.isValid())
.map(input => input.toString()); .map(item => item.toString());
const text = tokens.join(" "); const text = tokens.join(" ");
@ -108,11 +108,19 @@ export class SpfRecord {
const term = this.constructor.fields.find(f => f.key === name); const term = this.constructor.fields.find(f => f.key === name);
if (!term) { if (!term) {
throw new ValidationError(`Unknown term: ${name}`); throw new ValidationError(name ? `Unknown term: ${name}` : "Syntax error");
} }
const [directive, value] = token.split(term.separator); const [directive, value] = token.split(term.separator);
const [, qualifier, key] = directive.match(/^([-+?~]?)(\w*)/); const [, qualifier, key] = directive.match(/^([-+?~]?)(.*)/);
if (key !== name) {
throw new ValidationError(`Invalid separator for term: ${name}`);
}
if (token.includes(term.separator) && !value) {
throw new ValidationError(`No value specified for term: ${name}`);
}
result.push({ qualifier, key, value }); result.push({ qualifier, key, value });
} }
@ -135,7 +143,7 @@ export class SpfRecord {
const term = this.constructor.fields.find(d => d.key === input.key); const term = this.constructor.fields.find(d => d.key === input.key);
if (!term) { if (!term) {
throw new ValidationError(`Unknown term: ${input.key}`); throw new ValidationError(input.key ? `Unknown term: ${input.key}` : "Syntax error");
} }
if (term.position < lastPos) { if (term.position < lastPos) {
@ -144,8 +152,8 @@ export class SpfRecord {
throw new ValidationError(`Term "${lastDirective.key}" must come after "${term.key}"`); throw new ValidationError(`Term "${lastDirective.key}" must come after "${term.key}"`);
} }
if (term instanceof Modifier && input.qualifier) { if (!term.qualifierAllowed && input.qualifier) {
throw new ValidationError(`Modifier "${term.key}" must not have a qualifier`) throw new ValidationError(`Term "${term.key}" must not have a qualifier`)
} }
if (!input.value && term.valueRequirement === ValueRequirement.REQUIRED) { if (!input.value && term.valueRequirement === ValueRequirement.REQUIRED) {

View File

@ -8,10 +8,10 @@ export class TagListRecord {
this.text = text; this.text = text;
} }
static createFromFieldInputs(fieldInputs) { static createFromFieldInputItems(items) {
const tokens = fieldInputs const tokens = items
.filter(input => !input.field.isDisabled && input.isValid()) .filter(item => !item.field.isDisabled && item.isValid())
.filter(input => !input.field.defaultValue || input.getValue() !== input.field.defaultValue) .filter(item => item.field.defaultValue === null || item.getValue() !== item.field.defaultValue)
.map(input => input.toString()); .map(input => input.toString());
const text = tokens.join("; "); const text = tokens.join("; ");

View File

@ -4,6 +4,7 @@ import { ValueRequirement } from "./ValueRequirement.js";
export class Mechanism extends Term { export class Mechanism extends Term {
separator = ":"; separator = ":";
placeholder = null; placeholder = null;
qualifierAllowed = true;
constructor(key) { constructor(key) {
super(key); super(key);

View File

@ -4,6 +4,7 @@ import { validateSpfDomain } from "./utils.js";
export class Modifier extends Term { export class Modifier extends Term {
separator = "="; separator = "=";
qualifierAllowed = false;
constructor(key) { constructor(key) {
super(key); super(key);

View File

@ -3,9 +3,7 @@ import { Field } from "../Field.js";
export class Term extends Field { export class Term extends Field {
separator = null; separator = null;
isRequired = false;
position = null; position = null;
allowMultiple = false;
valueRequirement = ValueRequirement.REQUIRED; valueRequirement = ValueRequirement.REQUIRED;
constructor(key) { constructor(key) {
@ -42,21 +40,11 @@ export class Term extends Field {
// Builder methods // Builder methods
required() {
this.isRequired = true;
return this;
}
pos(i) { pos(i) {
this.position = i; this.position = i;
return this; return this;
} }
multiple() {
this.allowMultiple = true;
return this;
}
value(requirement) { value(requirement) {
this.valueRequirement = requirement; this.valueRequirement = requirement;
return this; return this;

View File

@ -3,6 +3,7 @@ import { ValidationError } from "../ValidationError.js";
export class VersionTerm extends Term { export class VersionTerm extends Term {
separator = "="; separator = "=";
qualifierAllowed = false;
constructor(key, version) { constructor(key, version) {
super(key); super(key);

View File

@ -17,9 +17,9 @@ export class EnumTag extends Tag {
getInputHtml(id) { getInputHtml(id) {
return `<select id="${id}" name="${this.key}" ${this.isRequired ? "required" : ""}>` + return `<select id="${id}" name="${this.key}" ${this.isRequired ? "required" : ""}>` +
(this.isRequired || this.defaultValue ? "" : `<option value="" selected>&lt;not set&gt;</option>`) + (this.isRequired || this.initialValue ? "" : `<option value="" selected>&lt;not set&gt;</option>`) +
this.values.map((value, i) => this.values.map((value, i) =>
`<option value="${value}" ${this.defaultValue === value ? "selected" : ""}> `<option value="${value}" ${this.initialValue === value ? "selected" : ""}>
${this.optionLabels[i] + (this.defaultValue === value ? " (Default)" : "")} ${this.optionLabels[i] + (this.defaultValue === value ? " (Default)" : "")}
</option>` </option>`
) + ) +

View File

@ -0,0 +1,28 @@
import { Tag } from "./Tag.js";
export class FlagsTag extends Tag {
constructor(key, flags) {
super(key);
this.flags = flags;
}
options(labels) {
this.flagLabels = labels;
return this;
}
getInputHtml() {
return `
<div style="display: flex; flex-direction: column; gap: 0.5rem">` +
this.flags.map((flag, i) => `
<label><input id="${this.id + "-" + flag}" type="checkbox"> ${this.flagLabels[i]}</label>
`).join("") +
"</div>";
}
getInputValue() {
return this.flags.map(flag => document.getElementById(this.id + "-" + flag).checked ? flag : "")
.filter(val => val)
.join(":");
}
}

View File

@ -23,7 +23,7 @@ export class IntTag extends Tag {
} }
getInputHtml(id) { getInputHtml(id) {
return `<input id="${id}" type="number" name="${this.key}" min="${this.min}" max="${this.max}" ${this.isRequired ? "required" : ""} placeholder="${this.defaultValue ?? ""}">`; return `<input id="${id}" type="number" name="${this.key}" min="${this.min}" max="${this.max}" ${this.isRequired ? "required" : ""} placeholder="${this.initialValue ?? ""}">`;
} }
getInputValue(id) { getInputValue(id) {

View File

@ -2,8 +2,8 @@ import { Field } from "../Field.js";
/** A tag within a DMARC/DKIM record */ /** A tag within a DMARC/DKIM record */
export class Tag extends Field { export class Tag extends Field {
isRequired = false;
defaultValue = null; defaultValue = null;
initialValue = null;
position = null; position = null;
constructor(key) { constructor(key) {
@ -34,13 +34,14 @@ export class Tag extends Field {
// Builder methods // Builder methods
required() { default(value) {
this.isRequired = true; this.defaultValue = value;
this.initialValue = value;
return this; return this;
} }
default(value) { initial(value) {
this.defaultValue = value; this.initialValue = value;
return this; return this;
} }

View File

@ -0,0 +1,19 @@
import { Tag } from "./Tag.js";
export class TextTag extends Tag {
constructor(key) {
super(key);
}
validate(value) {
return true;
}
getInputHtml(id) {
return `<input id="${id}" type="text" name="${this.key}" value="${this.initialValue ?? ""}" style="width: 100%">`;
}
getInputValue(id) {
return document.getElementById(id).value;
}
}

View File

@ -1,7 +1,9 @@
import { DkimRecord } from "../records/DkimRecord.js";
import { DmarcRecord } from "../records/DmarcRecord.js"; import { DmarcRecord } from "../records/DmarcRecord.js";
import { SpfRecord } from "../records/SpfRecord.js"; import { SpfRecord } from "../records/SpfRecord.js";
const records = { const records = {
"/dkim-creator": DkimRecord,
"/dmarc-creator": DmarcRecord, "/dmarc-creator": DmarcRecord,
"/spf-creator": SpfRecord, "/spf-creator": SpfRecord,
}; };
@ -36,7 +38,12 @@ document.getElementById("form").onchange = generate;
generate(); generate();
function generate() { function generate() {
const record = Record.createFromFieldInputs(inputs); const items = [];
for (const input of inputs) {
items.push(...input.items);
}
const record = Record.createFromFieldInputItems(items);
document.getElementById("record").value = record.text; document.getElementById("record").value = record.text;
} }

View File

@ -31,7 +31,7 @@ h1, h2 {
text-align: center; text-align: center;
} }
label { body > label {
color: #757575; color: #757575;
font-size: 0.8rem; font-size: 0.8rem;
} }
@ -122,7 +122,7 @@ li:not(:first-of-type) {
display: flex; display: flex;
} }
form label { form > label, details > label {
color: black; color: black;
font-weight: bold; font-weight: bold;
display: block; display: block;
@ -134,6 +134,7 @@ form input {
border: 1px solid #BDBDBD; border: 1px solid #BDBDBD;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
transition: border 200ms; transition: border 200ms;
accent-color: black;
} }
form input:focus { form input:focus {
@ -164,3 +165,29 @@ summary {
summary:hover { summary:hover {
color: black; color: black;
} }
.field-container {
display: flex;
flex-direction: column;
align-items: start;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.input-container {
display: flex;
gap: 0.5rem;
width: 100%;
}
.text-button {
background: none;
border: none;
cursor: pointer;
color: #757575;
transition: color 200ms;
}
.text-button:hover {
color: black;
}

57
dkim-creator/index.html Normal file
View File

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DKIM Record Creator - Generate DKIM DNS Records</title>
<link rel="stylesheet" href="/assets/styles/main.css">
<script type="module" src="/assets/scripts/ui/creator.js"></script>
</head>
<body>
<h1>DKIM Record Creator</h1>
<label for="record">DNS Record</label><br>
<input id="record" type="text" readonly>
<main>
<h2>Create a DKIM DNS Record</h2>
<p>Customize the options below to generate a DKIM record, which will be shown in the input field above.</p>
<form id="form"></form>
<hr>
<p>
This tool can be used to create DKIM DNS records, which together with a signature header can be used
for authorizing emails.
</p>
<p>
When setting up DKIM, you must first generate a public/private key pair. The private key is signed with
the email message and should be automatically included in the DKIM-signature email header, while the
public key should be pasted here in the DKIM DNS record, so that other email servers can verify the
signature.
</p>
<p>
Using DKIM prevents your emails from ending up in the receiver's spam folder. It is recommended to also
set up <a href="/spf-creator">SPF</a> (to authorize certain IP addresses to send mail) and
<a href="/dmarc-creator">DMARC</a> (to specify how to handle failed validation), to minimize the risk of
getting your emails flagged as spam.
</p>
<p>
This tool only helps with setting up the DNS record. The DKIM-signature email header should be
automatically set by your email provider when sending mails.
</p>
<center>
<h3>More tools:</h3>
<a href="/dkim-validator">DKIM Validator Tool</a> &bull;
<a href="/dmarc-creator">DMARC Creator Tool</a> &bull;
<a href="/spf-creator">SPF Creator Tool</a>
</center>
</main>
</body>
</html>

View File

@ -23,7 +23,7 @@
<hr> <hr>
<p> <p>
This tool allows you to create DMARC DNS records, which can be used when you are hosting an email-server 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. and want to provide an extra layer of security, so other email providers will trust your emails.
</p> </p>
@ -32,9 +32,16 @@
subdomain (e.g. _dmarc.example.com) with the content being the text above. subdomain (e.g. _dmarc.example.com) with the content being the text above.
</p> </p>
<p>
Using DMARC prevents your emails from ending up in the receiver's spam folder by telling email servers
how strictly to handle validation with <a href="/dkim-creator">DKIM</a> and
<a href="/spf-creator">SPF</a>. It is highly recommended to set up both of these before using DMARC.
</p>
<center> <center>
<h3>More tools:</h3> <h3>More tools:</h3>
<a href="/dmarc-validator">DMARC Validator Tool</a> &bull; <a href="/dmarc-validator">DMARC Validator Tool</a> &bull;
<a href="/DKIM-creator">DKIM Creator Tool</a> &bull;
<a href="/spf-creator">SPF Creator Tool</a> <a href="/spf-creator">SPF Creator Tool</a>
</center> </center>
</main> </main>

View File

@ -49,9 +49,17 @@
current IP address. See the <a href="/spf-macro-guide">Macro Guide</a> for a list of all macros. current IP address. See the <a href="/spf-macro-guide">Macro Guide</a> for a list of all macros.
</p> </p>
<p>
Using SPF prevents your emails from ending up in the receiver's spam folder. It is recommended to also
set up <a href="/dkim-creator">DKIM</a> (to authorize emails by signing them cryptographically) and
<a href="/dmarc-creator">DMARC</a> (to specify how to handle failed validation), to minimize the risk of
getting your emails flagged as spam.
</p>
<center> <center>
<h3>More tools:</h3> <h3>More tools:</h3>
<a href="/spf-validator">SPF Validator Tool</a> &bull; <a href="/spf-validator">SPF Validator Tool</a> &bull;
<a href="/dkim-creator">DKIM Creator Tool</a> &bull;
<a href="/dmarc-creator">DMARC Creator Tool</a> <a href="/dmarc-creator">DMARC Creator Tool</a>
</center> </center>
</main> </main>