Creating a Self-Hosted Contact Form for German Legal Compliance

TLDR: 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:

  1. Client requests a challenge (CAPTCHA image with UUID)
  2. User submits the form with their email address, message, and CAPTCHA solution
  3. 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.