Creating a Self-Hosted Contact Form for German Legal Compliance
Mar 6, 2025 - ⧖ 5.0 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.