Creating a Self-Hosted Contact Form for German Legal Compliance
Mar 6, 2025 - ⧖ 5 minTLDR: Check out the code: https://codeberg.org/madmo/contactd
The Legal Challenge
As a German citizen hosting content in Germany, I discovered my blog requires a proper imprint ("Impressum") with specific contact information. Without this, I could potentially face legal action! 🤯
Understanding Impressum Requirements
After researching various German laws regarding online publishing, I consulted an Impressum generator based on the current DDG (Digitale-Dienste-Gesetz). The required elements include:
- Full name
- Complete address
- Email address
- Phone number OR contact form
Balancing Compliance with Privacy
While I can't avoid sharing my name and address, I'm not comfortable posting my phone number on the internet. It's too vulnerable to spam and changing numbers is a hassle. The logical alternative? A contact form.
The Technical Challenge
My blog uses marmite to generate a static site backed by Git and CI workflows. Without a CMS to handle form submissions, I needed a solution.
Initially, I implemented a Google Forms link with notification routing, but this felt inelegant. I prefer not to rely on Google's infrastructure for my personal projects.
Searching for Alternatives
A quick search for self-hostable contact form solutions yielded disappointing results. Time to build my own!
Building a Custom Contact Form System 🛠️
My solution required several components:
- A backend to process and forward messages
- A lightweight frontend that works with a static site
- Spam protection mechanisms
The Rust Backend
I developed a compact Rust-based backend with:
- A RESTful API for handling form submissions
- CAPTCHA protection to prevent spam
- Static file serving for the frontend client
The workflow:
- Client requests a challenge (CAPTCHA image with UUID)
- User submits the form with their email address, message, and CAPTCHA solution
- Backend validates the CAPTCHA and sends an email to my inbox with the message (using the sender's email as the reply-to address)
I added some advanced features like domain-based routing, allowing different destination emails for different domains. Everything is packaged as a Nix flake with a straightforward NixOS module for easy deployment.
The Minimalist Frontend
I initially considered Vue 3 but found it excessive for such a simple form. Instead, I developed a lightweight JavaScript solution that dynamically creates the form without dependencies:
(async function () {
const script = document.currentScript;
const scriptOrigin = new URL(script.src).origin;
const response = await fetch(scriptOrigin + "/challenge");
const captcha_json = await response.json();
const container = document.createElement("div");
const reply_to = document.createElement("input");
reply_to.type = "text";
reply_to.placeholder = "Your E-Mail Address";
container.appendChild(reply_to);
const message = document.createElement("textarea");
message.placeholder = "Your Message";
container.appendChild(message);
const img = document.createElement("img");
img.src = "data:image/png;base64, " + captcha_json.captcha;
container.appendChild(img);
const captcha = document.createElement("input");
captcha.type = "text";
captcha.placeholder = "Captcha";
container.appendChild(captcha);
const submit = document.createElement("input");
submit.type = "submit";
submit.value = "Submit";
submit.onclick = async function () {
const contact_json = {
captcha_id: captcha_json.id,
captcha_text: captcha.value,
reply_to: reply_to.value,
message: message.value,
};
const response = await fetch(scriptOrigin + "/contact", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(contact_json),
});
if (response.status != 200) {
alert("Can't send contact message: " + (await response.text()));
} else {
alert("Sent contact message");
}
};
container.appendChild(submit);
script.insertAdjacentElement("afterend", container);
})();
The simplicity is intentional — it does exactly what's needed and nothing more.
NixOS Deployment Made Simple
I use NixOS for all my server setups, which provides the most hands-off experience I've ever had with self-hosting.
Here's how to deploy contactd on NixOS:
1. Add the module to your imports
(builtins.getFlake "git+https://git.h6t.eu/madmo/contactd.git?rev=c7783debffc3695ae9153f93542623df1a55e016").nixosModules.default
This fetches the flake at the specified revision and makes the default module available in your NixOS configuration.
2. Configure and enable the service
services.contactd.enable = true;
services.contactd.configuration = {
server = {
address = "127.0.0.1";
port = 9001;
};
email = {
host = "<YOUR_MAIL_RELAY_HOST>";
user = "<YOUR_MAIL_RELAY_USER>";
password = "<YOUR_MAIL_RELAY_PASSWORD>";
from = "<YOUR_MAIL_RELAY_ALLOWED_FROM_ADDRESS>";
};
default = {
difficulty = "Medium";
timeout = "30min";
email = "<YOUR_NOTIFICATION_EMAIL>";
};
};
This configuration makes the API available on localhost port 9001, ready for your reverse proxy connections.
3. Set up your reverse proxy (example with Nginx)
For a site accessible at contact.example.com
:
services.nginx.virtualHosts."contact.example.com" = {
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://localhost:9001/";
extraConfig = "proxy_set_header Origin '$http_origin';";
};
};
The Code
While this is a small project created to solve my specific need, I've made the code available for others facing similar challenges.
You can find the complete implementation at https://codeberg.org/madmo/contactd
Note: For more complex needs, you might be better served by a full-featured CMS with built-in contact form functionality.