Anleitungen14 Min. Lesezeit
HTML-Formulareinsendungen an deine E-Mail senden
Bekomm jede Formulareinsendung ohne Mailserver in dein Postfach: warum mailto: und PHPs mail() scheitern und ein Setup, das Spam filtert und dich antworten lässt.
Ein HTML-Formular kann von sich aus keine E-Mail senden, weil ein Browser keinen Weg hat, mit einem Mailserver zu sprechen. Richte die action des Formulars auf ein Formular-Backend aus, das den Post annimmt, Spam filtert, ihn speichert und dir und deinem Team eine E-Mail schickt. Nenne das E-Mail-Feld email, dann schreibt Reply in deinem Postfach direkt an die Person zurück, die das Formular ausgefüllt hat.
Die meisten Sites brauchen von einem Kontaktformular genau eins: die Nachricht in einem Postfach, das jemand liest. Das ist schwieriger, als es aussieht. HTML hat ein <form>-Element, aber keine Möglichkeit, Mails zu senden, und die alten Abkürzungen schieben die Arbeit entweder dem Besucher zu oder landen im Spam. Diese Anleitung erklärt, warum, vergleicht die Optionen ehrlich und richtet ein Formular ein, das dir auf jeder Site und mit jedem Stack E-Mails schickt.
Warum ein Formular nicht von allein E-Mails senden kann
Wenn ein Besucher auf Send klickt, macht der Browser einen HTTP-Request an die URL in der action des Formulars. Mehr kann ein Formular nicht. Eine E-Mail zu senden, erfordert eine SMTP-Unterhaltung mit einem Mailserver, von einer Maschine aus, der der empfangende Server vertraut. Ein Browser kann diese Verbindung nicht öffnen, und selbst wenn, würde Mail vom Laptop eines beliebigen Besuchers abgelehnt. Zwischen Formular und Postfach sitzt also immer etwas auf einem Server: dein eigener Code, ein Skript bei deinem Host oder ein Dienst.
Mit einem Formular-Backend ist dieses Etwas eine URL. Das Formular sendet an https://ikiform.com/f/<id>, und Ikiform erledigt den Rest:
- Der Besucher sendet dein Formular ab, und der Browser oder dein Server schickt es an deinen Ikiform-Endpunkt.
- Ikiform prüft die Felder gegen die Definition des Formulars, bewertet den Post auf Spam und speichert ihn.
- Ein Post, der den Spam-Schwellenwert erreicht, wird als Spam gespeichert. Er bekommt dieselbe Erfolgsantwort, löst aber weder E-Mail noch Webhook aus.
- Jeder andere Post sendet eine E-Mail an jeden Empfänger, mit der Adresse des Besuchers als Reply-To.
- Die E-Mail kommt Sekunden später in deinem Postfach an. Mit Antworten schreibst du dem Besucher.
Sechs Wege, ein Formular in dein Postfach zu bekommen
Jeder davon funktioniert für irgendwen. Die Tabelle zeigt, was dich jeder kostet, sobald echte Besucher und echte Bots das Formular nutzen.
| Ansatz | Stark bei | Nachteile |
|---|---|---|
action="mailto:you@example.com" | Kein Server, kein Konto, ein Attribut. | Nichts wird gesendet. Der Browser öffnet die Mail-App des Besuchers mit einem Entwurf oder tut gar nichts, wenn keine Mail-App eingerichtet ist. Deine Adresse steht für Scraper in der Seite. Siehe die Gmail-Anleitung. |
Ein PHP-Skript mit mail() | Läuft auf fast jedem Shared-PHP-Host, ohne Drittanbieter. | Hängt vom Mail-Setup des Hosts ab, und Mail, die angeblich von deiner Domain kommt, fällt oft bei SPF oder DMARC durch und landet im Spam. Kein Spamfilter, keine Speicherung und auf statischen Hosts überhaupt kein PHP. Siehe ohne PHP. |
| Ein E-Mail-SDK für den Browser, etwa EmailJS | Kein Server. E-Mail-Vorlagen, die du in deren Dashboard gestaltest. | Die Schlüssel und Vorlagen-IDs stehen im JavaScript deiner Seite, sodass jeder deine Vorlagen mit eigenem Text verschicken kann. Siehe die EmailJS-Alternative. |
| Deine eigene Route plus eine E-Mail-API wie Resend, Postmark, Amazon SES oder Nodemailer über SMTP | Volle Kontrolle: dein Betreff, dein HTML, eine Bestätigung an den Besucher. | Du baust Validierung, Spamfilter, Rate-Limits, Speicherung und Wiederholungsversuche, verifizierst eine Absenderdomain und brauchst selbst für eine statische Site einen Server. Siehe die Next.js-Anleitung. |
| Google Apps Script als Web-App | Läuft auf einem Google-Konto, das du schon hast. | Skript-Kontingente begrenzen, wie viele Empfänger es pro Tag anschreiben kann, die URL hat keinen Spamschutz, und du pflegst das Skript. |
| Ein Formular-Backend wie Ikiform, Formspree oder Web3Forms | Eine URL in action. Spamfilter, Speicherung und E-Mail ohne Server. | Layout und Betreff der E-Mail bestimmt der Dienst. Bei Ikiform wird dem Besucher nie etwas gemailt. |
Wenn du eine vollständig gebrandete E-Mail oder eine Bestätigung an den Besucher brauchst, schreib deine eigene Route mit einer E-Mail-API. Wenn du die Nachricht im Postfach und eine Antwortmöglichkeit willst, ohne Spam, ist ein Formular-Backend der wenigste Code. Der Rest dieser Anleitung nutzt Ikiform.
Das Formular
Drei Felder decken die meisten Kontaktformulare ab: wer die Person ist, wohin die Antwort geht und was sie will. Wähle unten deinen Stack; die Wahl gilt für jedes Codebeispiel in diesen Anleitungen.
Dieses Formular funktioniert ganz ohne JavaScript: Der Browser sendet es, Ikiform schickt dir eine E-Mail, und der Besucher landet auf der Seite in _redirect. Unter jedem Feld steht ein leerer Platz für Fehler, den das Skript unten nutzt.
<form id="contact" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120" />
<p data-error="name"></p>
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p data-error="email"></p>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10"></textarea>
<p data-error="message"></p>
<input type="hidden" name="page" value="/contact" />
<input type="hidden" name="_redirect" value="https://example.com/contact/sent" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send message</button>
<p id="contact-status" role="status"></p>
</form>Das Skript hält den Besucher auf der Seite. Es sendet mit fetch, schreibt jeden Serverfehler unter sein Feld und ersetzt das Formular durch eine Zeile, die die Adresse wiederholt, an die du antworten wirst, damit ein Tippfehler leicht auffällt.
const form = document.getElementById("contact");
const status = document.getElementById("contact-status");
const button = form.querySelector("button");
form.addEventListener("submit", async (event) => {
event.preventDefault();
for (const slot of form.querySelectorAll("[data-error]")) slot.textContent = "";
button.disabled = true;
status.textContent = "Sending";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
const email = form.elements.namedItem("email").value;
const done = document.createElement("p");
done.setAttribute("role", "status");
done.textContent = `Sent. We'll reply to ${email}.`;
form.replaceWith(done);
return;
}
for (const [field, messages] of Object.entries(result.error.fields ?? {})) {
const slot = form.querySelector(`[data-error="${field}"]`);
if (slot) slot.textContent = messages[0];
}
status.textContent = result.error.fields ? "Check the fields above." : result.error.message;
} catch {
status.textContent = "Couldn't send. Check your connection and try again.";
}
button.disabled = false;
});Eine Server Action sendet die Nachricht mit deinem Secret Key. Sie ordnet error.fields von Ikiform je einer Meldung pro Feld zu und gibt die eingegebenen Werte zurück, sodass bei einem Fehler nichts verloren geht, was der Besucher geschrieben hat.
"use server";
import { headers } from "next/headers";
export interface ContactState {
sent: boolean;
email?: string;
errors: Record<string, string>;
values?: { name: string; email: string; message: string };
}
export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
const values = {
name: String(formData.get("name") ?? "").trim(),
email: String(formData.get("email") ?? "").trim(),
message: String(formData.get("message") ?? "").trim(),
};
const origin = (await headers()).get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...values, page: "/contact", _gotcha: String(formData.get("_gotcha") ?? "") }),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { sent: true, email: values.email, errors: {} };
const errors: Record<string, string> = {};
for (const [field, messages] of Object.entries(result?.error?.fields ?? {})) {
errors[field] = (messages as string[])[0] ?? "Check this field.";
}
if (!Object.keys(errors).length) errors.form = result?.error?.message ?? "Couldn't send your message. Try again.";
return { sent: false, errors, values };
}"use client";
import { useActionState } from "react";
import { type ContactState, sendMessage } from "./actions";
const initial: ContactState = { sent: false, errors: {} };
export function ContactForm() {
const [state, action, pending] = useActionState(sendMessage, initial);
if (state.sent) return <p role="status">Sent. We'll reply to {state.email}.</p>;
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required defaultValue={state.values?.name} />
{state.errors.name ? <p role="alert">{state.errors.name}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.values?.email} />
{state.errors.email ? <p role="alert">{state.errors.email}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} defaultValue={state.values?.message} />
{state.errors.message ? <p role="alert">{state.errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.errors.form ? <p role="alert">{state.errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
);
}Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform Allowed domains und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das Rate-Limit Relaxed oder Custom, und halte IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen. Jedes Server-Beispiel reicht den Origin-Header des Browsers weiter, falls vorhanden, weil das eigene fetch eines Servers keinen sendet und Ikiform kostenlose localhost-Testeinsendungen daran erkennt.
Mehr zu Next.js? Lies Ein Next.js-Kontaktformular, das E-Mails sendet.
Zod prüft jedes Feld in der Action, bevor etwas gesendet wird, und isInputError liefert dir die Meldungen pro Feld. Die Seite muss bei Bedarf gerendert werden, also behalte prerender = false und füge einen Adapter hinzu.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
sendMessage: defineAction({
accept: "form",
input: z.object({
name: z.string().trim().min(1, "Tell us your name.").max(120),
email: z.email("Enter an address we can reply to."),
message: z.string().trim().min(10, "Write at least a sentence."),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const origin = context.request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...input, page: "/contact" }),
});
const result = await response.json();
if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.error.message });
return { email: input.email };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.sendMessage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---
{result?.data ? (
<p role="status">Sent. We'll reply to {result.data.email}.</p>
) : (
<form method="POST" action={actions.sendMessage}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
{fields.name && <p role="alert">{fields.name[0]}</p>}
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{fields.email && <p role="alert">{fields.email[0]}</p>}
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10"></textarea>
{fields.message && <p role="alert">{fields.message[0]}</p>}
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
<button>Send message</button>
</form>
)}Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform Allowed domains und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das Rate-Limit Relaxed oder Custom, und halte IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen. Jedes Server-Beispiel reicht den Origin-Header des Browsers weiter, falls vorhanden, weil das eigene fetch eines Servers keinen sendet und Ikiform kostenlose localhost-Testeinsendungen daran erkennt.
Die Serverfunktion liest die FormData in .validator(), sendet sie vom Server und gibt entweder die Antwortadresse oder einen Fehler pro Feld zurück.
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
type Outcome = { sent: true; email: string } | { sent: false; errors: Record<string, string> };
const sendMessage = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return {
name: String(data.get("name") ?? "").trim(),
email: String(data.get("email") ?? "").trim(),
message: String(data.get("message") ?? "").trim(),
_gotcha: String(data.get("_gotcha") ?? ""),
};
})
.handler(async ({ data }): Promise<Outcome> => {
const origin = getRequestHeader("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...data, page: "/contact" }),
});
const result = await response.json();
if (result.ok) return { sent: true, email: data.email };
const errors: Record<string, string> = { form: String(result.error.message) };
for (const [field, messages] of Object.entries(result.error.fields ?? {})) {
errors[field] = String((messages as string[])[0]);
}
return { sent: false, errors };
});
export const Route = createFileRoute("/contact")({ component: Contact });
function Contact() {
const send = useServerFn(sendMessage);
const [outcome, setOutcome] = useState<Outcome | null>(null);
const [pending, setPending] = useState(false);
const errors = outcome && !outcome.sent ? outcome.errors : {};
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
try {
setOutcome(await send({ data }));
} catch {
setOutcome({ sent: false, errors: { form: "Couldn't send your message. Try again." } });
} finally {
setPending(false);
}
}
if (outcome?.sent) return <p role="status">Sent. We'll reply to {outcome.email}.</p>;
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{errors.name ? <p role="alert">{errors.name}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{errors.email ? <p role="alert">{errors.email}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} />
{errors.message ? <p role="alert">{errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form && !errors.name && !errors.email && !errors.message ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
);
}Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform Allowed domains und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das Rate-Limit Relaxed oder Custom, und halte IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen. Jedes Server-Beispiel reicht den Origin-Header des Browsers weiter, falls vorhanden, weil das eigene fetch eines Servers keinen sendet und Ikiform kostenlose localhost-Testeinsendungen daran erkennt.
Die action der Route leitet die Nachricht weiter und gibt die Feldfehler von Ikiform über actionData zurück, wobei data() den Statuscode von Ikiform beibehält.
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/contact";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const email = String(formData.get("email") ?? "").trim();
const origin = request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
name: String(formData.get("name") ?? "").trim(),
email,
message: String(formData.get("message") ?? "").trim(),
page: "/contact",
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (result.ok) return { sent: true as const, email };
const fields: Record<string, string[]> = result.error.fields ?? {};
return data(
{ sent: false as const, message: String(result.error.message), fields },
{ status: response.status },
);
}
export default function Contact({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const pending = navigation.state === "submitting";
if (actionData?.sent) return <p role="status">Sent. We'll reply to {actionData.email}.</p>;
const fields = actionData?.fields ?? {};
return (
<Form method="post">
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{fields.name ? <p role="alert">{fields.name[0]}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{fields.email ? <p role="alert">{fields.email[0]}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} />
{fields.message ? <p role="alert">{fields.message[0]}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{actionData && !Object.keys(fields).length ? <p role="alert">{actionData.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</Form>
);
}Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform Allowed domains und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das Rate-Limit Relaxed oder Custom, und halte IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen. Jedes Server-Beispiel reicht den Origin-Header des Browsers weiter, falls vorhanden, weil das eigene fetch eines Servers keinen sendet und Ikiform kostenlose localhost-Testeinsendungen daran erkennt.
Die Standard-Action gibt fail() mit den Feldfehlern zurück. update({ reset: false }) behält, was der Besucher getippt hat, wenn der Server mit einem Fehler antwortet. Deklariere IKIFORM_SECRET_KEY in src/env.ts mit defineEnvVars, damit $app/env/private ihn lesen kann.
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, fetch, url }) => {
const data = await request.formData();
const values = {
name: String(data.get("name") ?? "").trim(),
email: String(data.get("email") ?? "").trim(),
message: String(data.get("message") ?? "").trim(),
};
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? url.origin,
},
body: JSON.stringify({ ...values, page: "/contact", _gotcha: String(data.get("_gotcha") ?? "") }),
});
const result = await response.json();
if (!result.ok) {
const fields: Record<string, string[]> = result.error.fields ?? {};
return fail(response.status, { values, fields, message: String(result.error.message) });
}
return { sentTo: values.email };
},
} satisfies Actions;<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let pending = $state(false);
const fields = $derived<Record<string, string[]>>(form?.fields ?? {});
</script>
{#if form?.sentTo}
<p role="status">Sent. We'll reply to {form.sentTo}.</p>
{:else}
<form
method="POST"
use:enhance={() => {
pending = true;
return async ({ update }) => {
await update({ reset: false });
pending = false;
};
}}
>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
{#if fields.name}<p role="alert">{fields.name[0]}</p>{/if}
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{#if fields.email}<p role="alert">{fields.email[0]}</p>{/if}
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10"></textarea>
{#if fields.message}<p role="alert">{fields.message[0]}</p>{/if}
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if form?.message && !Object.keys(fields).length}<p role="alert">{form.message}</p>{/if}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
{/if}Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform Allowed domains und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das Rate-Limit Relaxed oder Custom, und halte IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen. Jedes Server-Beispiel reicht den Origin-Header des Browsers weiter, falls vorhanden, weil das eigene fetch eines Servers keinen sendet und Ikiform kostenlose localhost-Testeinsendungen daran erkennt.
Eine Server-Route hält den Secret Key und reicht die Meldung von Ikiform als message von createError und die Feldfehler in dessen data weiter; $fetch gibt beides auf dem Client als data.message und data.data.fields zurück. Nuxt füllt runtimeConfig.ikiformSecretKey aus NUXT_IKIFORM_SECRET_KEY.
export default defineEventHandler(async (event) => {
const body = await readBody<{ name?: string; email?: string; message?: string; _gotcha?: string }>(event);
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
name: body.name?.trim() ?? "",
email: body.email?.trim() ?? "",
message: body.message?.trim() ?? "",
page: "/contact",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: String(result.error.message),
data: { fields: result.error.fields ?? {} },
});
}
return { sentTo: body.email };
});export default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
});<script setup lang="ts">
const values = reactive({ name: "", email: "", message: "", _gotcha: "" });
const fields = ref<Record<string, string[]>>({});
const error = ref("");
const sentTo = ref("");
const sending = ref(false);
async function send() {
sending.value = true;
fields.value = {};
error.value = "";
try {
const result = await $fetch("/api/contact", { method: "POST", body: values });
sentTo.value = result.sentTo ?? values.email;
} catch (failure) {
const data = (failure as { data?: { message?: string; data?: { fields?: Record<string, string[]> } } }).data;
fields.value = data?.data?.fields ?? {};
error.value = data?.message ?? "Couldn't send your message. Try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sentTo" role="status">Sent. We'll reply to {{ sentTo }}.</p>
<form v-else @submit.prevent="send">
<label for="name">Name</label>
<input id="name" v-model="values.name" name="name" autocomplete="name" required />
<p v-if="fields.name" role="alert">{{ fields.name[0] }}</p>
<label for="email">Your email</label>
<input id="email" v-model="values.email" name="email" type="email" autocomplete="email" required />
<p v-if="fields.email" role="alert">{{ fields.email[0] }}</p>
<label for="message">Message</label>
<textarea id="message" v-model="values.message" name="message" rows="6" required minlength="10" />
<p v-if="fields.message" role="alert">{{ fields.message[0] }}</p>
<input v-model="values._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error && !Object.keys(fields).length" role="alert">{{ error }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
</form>
</template>Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform Allowed domains und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das Rate-Limit Relaxed oder Custom, und halte IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen. Jedes Server-Beispiel reicht den Origin-Header des Browsers weiter, falls vorhanden, weil das eigene fetch eines Servers keinen sendet und Ikiform kostenlose localhost-Testeinsendungen daran erkennt.
Ohne eigenen Server sendet der Browser direkt an Ikiform. In diesen Code kommt kein Schlüssel; Allowed domains entscheidet, welche Sites senden dürfen.
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export function ContactForm() {
const [sentTo, setSentTo] = useState("");
const [sending, setSending] = useState(false);
const [fields, setFields] = useState<Record<string, string[]>>({});
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setSending(true);
setFields({});
setError("");
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
setSentTo(String(body.get("email")));
return;
}
setFields(result.error.fields ?? {});
setError(result.error.message);
} catch {
setError("Couldn't reach the server. Try again.");
} finally {
setSending(false);
}
}
if (sentTo) return <p role="status">Sent. We'll reply to {sentTo}.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{fields.name ? <p role="alert">{fields.name[0]}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{fields.email ? <p role="alert">{fields.email[0]}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} />
{fields.message ? <p role="alert">{fields.message[0]}</p> : null}
<input type="hidden" name="page" value="/contact" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error && !Object.keys(fields).length ? <p role="alert">{error}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</form>
);
}Derselbe browserseitige Post als Vue-Komponente. Das Formular behält seine action, sendet also auch dann, wenn das Skript nicht lädt.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const sentTo = ref("");
const sending = ref(false);
const fields = ref<Record<string, string[]>>({});
const error = ref("");
async function send(event: SubmitEvent) {
const body = new FormData(event.target as HTMLFormElement);
sending.value = true;
fields.value = {};
error.value = "";
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
sentTo.value = String(body.get("email"));
return;
}
fields.value = result.error.fields ?? {};
error.value = result.error.message;
} catch {
error.value = "Couldn't reach the server. Try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sentTo" role="status">Sent. We'll reply to {{ sentTo }}.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<p v-if="fields.name" role="alert">{{ fields.name[0] }}</p>
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p v-if="fields.email" role="alert">{{ fields.email[0] }}</p>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10" />
<p v-if="fields.message" role="alert">{{ fields.message[0] }}</p>
<input type="hidden" name="page" value="/contact" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error && !Object.keys(fields).length" role="alert">{{ error }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
</form>
</template>- Ersetze
frm_xxxxxxxxxxxxxxxxdurch die ID deines Formulars aus dem Dashboard. name="email"macht dieses Feld zur Reply-To-Adresse der Benachrichtigungs-E-Mail. Behalte den Namen, auch wenn das Label „Deine E-Mail“ lautet.pageist ein gewöhnliches verstecktes Feld. Es erscheint in der E-Mail, sodass du weißt, von welcher Seite die Nachricht kam._gotchaist der Honeypot. Menschen sehen ihn nie; ein Bot, der ihn ausfüllt, wird als Spam gespeichert und löst keine E-Mail aus._redirectfunktioniert nur mit einerhttps:-URL auf einer der Allowed domains des Formulars. Lass es weg und setze stattdessen Redirect after submit in den Einstellungen des Formulars, sonst zeigt Ikiform seine eigene Dankeseite.
Die Framework-Anleitungen gehen weiter: Next.js, Astro, SvelteKit, Nuxt, React, Vue und einfaches HTML. Website-Baukästen wie Webflow und Carrd haben eigene Seiten, und die Kontaktformular-Vorlage ergänzt ein Themenfeld.
Definiere die Felder auch im Dashboard, dann lehnt Ikiform einen nicht passenden Post mit 422 validation_failed und einer Meldung pro Feld ab, die der Code oben neben jedem Eingabefeld anzeigt. Probier es hier aus; das führt denselben Validator in deinem Browser aus:
Vorschau
Führt den Validator von Ikiform in deinem Browser aus. Es wird nichts gesendet.
Wie die E-Mail aussieht
Jede angenommene Einsendung löst eine schlichte, gut lesbare E-Mail aus. Hier die Textversion einer solchen:
From: Ikiform <notifications@ikiform.com>
To: you@yourcompany.com
Reply-To: ada@example.com
Subject: New submission: Contact
Contact got a new submission on Oct 25, 2026, 9:14 AM UTC.
name: Ada Lovelace
email: ada@example.com
message: Do you ship to Canada? I'd like to order 40 units before December.
page: /contact
View it in Ikiform:
https://ikiform.com/dashboard/submissions?form=frm_xxxxxxxxxxxxxxxx&submission=sub_...- Sie kommt von
notifications@ikiform.com. Du richtest keine DNS-Einträge und keine Absenderdomain ein, weil die Mail von der Domain von Ikiform gesendet wird. - Der Betreff lautet immer
New submission: <form name>._subjectändert nicht den Betreff der Benachrichtigungs-E-Mails. Es wird alsmeta.subjectan Webhooks und die API weitergegeben. Um Formulare auseinanderzuhalten, gib jedem Formular im Dashboard einen klaren Namen, etwa „Kontakt, Preisseite“. - Felder erscheinen in der Reihenfolge, in der sie gesendet wurden, beschriftet mit ihrem
name. Felder, die mit_beginnen, werden weggelassen. - Ein sehr langes Formular oder eine sehr lange Antwort wird gekürzt, und die E-Mail sagt, wie viele weitere Felder im Dashboard stehen. Die Seite zu Limits nennt die Zahlen.
- Felder, die wie Passwörter oder Kartennummern aussehen, werden weggelassen, und die E-Mail zu einem Post, der sie enthält, wird zur Prüfung zurückgehalten. Sammle mit einem Formular-Backend keine Zugangsdaten.
- Workspace-Mitglieder bekommen einen Button View submission. Personen außerhalb des Workspace bekommen die Felder und einen Abmeldelink.
Wer die E-Mail bekommt
Empfänger werden pro Formular im Dashboard festgelegt, nie durch das Formular selbst. Ein Feld, das einen Empfänger angibt, würde jedem erlauben, über dein Formular beliebige Leute anzuschreiben, deshalb hat Ikiform keines.
| Wer | Wie sie hinzugefügt werden | Hinweise |
|---|---|---|
| Der Standard | Nichts zu tun | Das Mitglied, das als Notification email des Workspace gewählt ist, oder jeder Owner des Workspace, wenn keines gewählt ist |
| Ausgewählte Mitglieder | Hake sie in den Einstellungen Notifications des Formulars an | Nur Mitglieder mit verifizierter E-Mail-Adresse |
| Personen außerhalb des Workspace | Add someone outside the workspace, dann Send link | Sie bekommen erst E-Mails, nachdem sie bestätigt haben. Bis zu 5 pro Formular, und jede Person kann sich abmelden |
Jedes Formular mailt an höchstens 10 Adressen. Jeder Empfänger bekommt seine eigene Kopie statt eines gemeinsamen CC, sodass niemand die Adressen der anderen sieht. Wenn das Postfach eines Empfängers Bounces erzeugt, pausiert Ikiform eine Weile die E-Mails an diese Adresse, und das Dashboard zeigt daneben Emails bounced. Mehrere Empfänger behandelt das Weiterleiten nach Thema, was Empfänger allein nicht können.
Der Person antworten, nicht Ikiform
Ikiform setzt die Reply-To-Adresse zuerst aus einem Feld _replyto, dann aus einem Feld namens email, email_address, your_email, reply_to, contact_email. Der Wert muss eine einzelne gültige E-Mail-Adresse sein, sonst wird er ignoriert. Ist ein Reply-To gesetzt, öffnet Reply in Gmail, Outlook oder Apple Mail eine Antwort an den Besucher, und deine Antwort kommt von deiner eigenen Adresse.
Ohne Reply-To geht eine Antwort an notifications@ikiform.com, das niemand liest. Das ist die häufigste Überraschung, und meist steckt ein Feld namens user_email oder Email Address mit Leerzeichen dahinter. Benenne es um oder füge _replyto hinzu. Die Reply-To-Anleitung behandelt Formulare mit zwei E-Mail-Feldern und Baukästen, die Felder für dich umbenennen.
Wenn viel auf einmal ankommt
Jedes Formular sendet bis zu 50 Benachrichtigungs-E-Mails pro Stunde, oder 10, solange der Workspace jünger als 7 Tage ist. Darüber hinaus werden die restlichen Einsendungen der Stunde wie gewohnt gespeichert und kommen nach Ende der Stunde als eine Sammel-E-Mail an. Die Grenze schützt dein Postfach und den Ruf von Ikiforms Mails bei deinem Mail-Anbieter, wenn ein Formular irgendwo Großes geteilt wird oder ein Bot an den Filtern vorbeikommt.
- Die ersten 50 Einsendungen an ein Formular in einer vollen Stunde lösen wie gewohnt je eine E-Mail aus.
- Spätere Einsendungen in derselben Stunde werden gespeichert und sofort im Dashboard angezeigt, ohne einzelne E-Mail.
- Nach Ende der Stunde bekommt jeder Empfänger eine Sammel-E-Mail, die sie zählt und auf das Dashboard verlinkt.
Der Betreff der Sammel-E-Mail besteht aus der Anzahl und dem Formularnamen, und sie listet weder die Felder auf noch setzt sie ein Reply-To, also beantworte diese Nachrichten aus dem Dashboard. Wenn du regelmäßig über der Grenze liegst, ist E-Mail nicht der richtige Kanal für dieses Formular: Schick es stattdessen über einen Webhook an Slack oder ein gemeinsames Postfach.
Spam aus deinem Postfach heraushalten
E-Mail macht Spam teuer: Jeder Bot-Post ist eine Benachrichtigung, die jemand öffnet. Ikiform speichert Spam mit einer normalen Erfolgsantwort, der Bot lernt also nichts, und sendet keine E-Mail dafür. Vier Dinge entscheiden, was als Spam zählt:
| Abwehr | Was sie abfängt | Einrichtung |
|---|---|---|
| Honeypot | Bots, die jedes Eingabefeld ausfüllen | Das versteckte Eingabefeld _gotcha |
| Spam-Bewertung | Links im Namensfeld, drei oder mehr Links, bekannte Spam-Phrasen, Wegwerfadressen und identische Wiederholungen. Ab 5 ist der Post Spam | Keine |
| Erlaubte Domains | Posts von anderen Sites | Füge deine Domain in den Einstellungen des Formulars hinzu |
| Menschlichkeitsprüfung | Bots, die einen echten Browser ausführen | Setze Spam check auf Ikiform |
Schau ab und zu in den Filter Spam im Dashboard. Wenn dort eine echte Nachricht landet, markiere sie als kein Spam, dann geht ihre E-Mail raus. Die Spam-Dokumentation erklärt jedes Signal, auch wann sich die Menschlichkeitsprüfung lohnt.
Was E-Mail nicht kann
Ikiform schickt der Person, die das Formular ausfüllt, keine E-Mail. Benachrichtigungs-E-Mails gehen nur an dich und dein Team, es gibt also keinen Autoresponder, keine Bestätigungs-E-Mail und kein Double-Opt-in. Verschicke sie aus deinem E-Mail-Tool, ausgelöst durch einen Webhook. Wenn deine Site „Wir haben deine Nachricht erhalten“ verspricht, zeig das nach dem Absenden auf der Seite an, wie es der Code oben schon tut.
- Ein gemeinsames Team-Postfach. Füge die Team-Adresse als Empfänger hinzu oder leite sie über deinen Mail-Anbieter weiter.
- Ein Chat-Kanal. Schick jede Einsendung per Webhook an Slack, Discord oder Microsoft Teams. Chat passt zu Formularen, die mehr als ein paar Nachrichten am Tag bekommen.
- Eine Tabelle oder ein CRM. Google Sheets oder jedes Tool über Zapier oder n8n.
- Anhänge. Datei-Uploads werden noch nicht unterstützt. Ein Post mit einer Datei bekommt
422 files_not_supported. Bitte stattdessen um einen Link zu einer geteilten Datei.
E-Mail abschalten
Manche Formulare sind ohne E-Mail besser, etwa eine stark genutzte Newsletter-Anmeldung. Schalte Email new submissions in den Einstellungen Notifications des Formulars aus. Einsendungen werden weiterhin gespeichert, und Webhooks feuern weiterhin.
Wähle deinen Fall
Das Setup oben ist überall gleich. Diese Seiten behandeln ein bestimmtes Postfach, einen bestimmten Stack oder das Tool, von dem du wegwechselst.
- Ohne PHPErsetze ein contact.php-Skript, das mail() aufruft, durch ein Formular, das an einen gehosteten Endpunkt sendet, mit deinen Feldnamen, deiner Dankeseite und Antworten.
- An GmailBekomm Formulareinsendungen in Gmail ohne mailto:-Links, App-Passwörter oder Apps Script, dann beschrifte sie, halte sie aus dem Spam heraus und antworte aus Gmail.
- Mehrere EmpfängerMail ein Formular an dein ganzes Team oder leite jede Nachricht nach Thema an die richtige Person, ohne dass das Formular selbst bestimmt, wer die Mail bekommt.
- Aus deinem Postfach antwortenSorg dafür, dass Antworten auf eine Formular-Benachrichtigung an die Person gehen, die das Formular ausgefüllt hat: welche Feldnamen funktionieren, wann _replyto nötig ist und warum Antworten fehlgehen.
- Alternative zu EmailJSLös ein Kontaktformular von EmailJS: warum E-Mail-Schlüssel im Browser ein Risiko sind, was sich im Code ändert und worauf du verzichtest, etwa die automatische Antwort.
- Next.jsZwei Wege, ein Next.js-Formular per Server Action zu mailen: selbst mit Resend oder Nodemailer senden oder an ein Formular-Backend weiterleiten, ehrlich verglichen.
Prüfen, ob es funktioniert
Geh das durch, bevor du das Formular live schaltest. Ikiform hat keinen kostenlosen Tarif. Testen ist kostenlos: Ein Workspace ohne Tarif nimmt 50 Testeinsendungen pro Monat von localhost an. Für den Livebetrieb wähle Pro ab $6/Monat für 500 Einsendungen oder zahle einmalig: 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt. Siehe Preise.
- Füge unter Allowed domains des Formulars die Domain deiner Site hinzu, plus
localhost, während du entwickelst. Lass das Feld leer, um Posts von überall anzunehmen. - Prüfe in den Einstellungen Notifications des Formulars, ob Email new submissions an ist und die richtigen Leute angehakt sind.
- Füll das Formular auf deiner Seite aus und sende es ab. Du landest auf deiner Dankeseite oder der gehosteten von Ikiform.
- Öffne Submissions im Dashboard. Die neue Einsendung ist da, mit jedem Feld, das du gesendet hast.
- Öffne die Benachrichtigungs-E-Mail. Der Betreff ist
New submission:plus der Name des Formulars, und jedes gesendete Feld ist aufgeführt. - Klick auf Reply. Die Zeile To zeigt die Adresse, die du ins Formular getippt hast, nicht die von Ikiform.
- Sende einen Post mit ausgefülltem Honeypot,
_gotcha=bot. Er bekommt trotzdem eine Erfolgsantwort, aber die Einsendung wird als Spam gespeichert und löst weder E-Mail noch Webhook aus. - Nichts angekommen? Stell den Statusfilter im Dashboard auf Spam. Spam bekommt dieselbe Erfolgsantwort wie eine echte Einsendung.
Fehlerbehebung
| Problem | Was zu tun ist |
|---|---|
| Die Einsendung ist im Dashboard, aber keine E-Mail kam an | Schau zuerst in deinen Spam- oder Werbung-Ordner und füge notifications@ikiform.com zu deinen Kontakten hinzu. Prüfe dann die Einstellungen Notifications des Formulars: E-Mail ist vielleicht aus, bei einer externen Adresse steht vielleicht noch Waiting for confirmation, oder sie zeigt Emails bounced. |
| Die Einsendung ist im Dashboard, aber keine E-Mail und kein Webhook kam an | Sie wurde als Spam gespeichert. Spam löst nichts aus. Öffne sie unter dem Filter Spam, um die Gründe zu sehen, und markiere sie als kein Spam, um ihre E-Mail und Webhooks zu senden. |
| Reply geht an notifications@ikiform.com | Es wurde kein Reply-To gefunden. Nenne das E-Mail-Feld email, email_address, your_email, reply_to oder contact_email, oder füge ein Feld _replyto mit der Adresse des Besuchers hinzu. |
| E-Mails hörten auf, und stattdessen kam eine Sammel-E-Mail | Das Formular hat 50 E-Mails in einer Stunde überschritten (10 bei einem neuen Workspace). Alles steht im Dashboard. Such unter Spam nach einem Bot-Ansturm oder nutze für stark genutzte Formulare einen Webhook. |
_subject ändert den Betreff nicht | _subject ändert nicht den Betreff der Benachrichtigungs-E-Mails. Es wird als meta.subject an Webhooks und die API weitergegeben. |
403 origin_not_allowed, oder jeder Post landet im Spam | Die Domain der Seite steht nicht unter Allowed domains des Formulars. Füge sie hinzu, plus localhost für lokale Tests. Subdomains einer eingetragenen Domain sind erlaubt. |
422 validation_failed | Der Post passt nicht zur Felddefinition des Formulars. error.fields nennt jedes Feld und den Grund. Gleiche den name des Eingabefelds an die Feld-ID an. |
402 test_limit_reached | Der Workspace hat keinen Tarif und hat seine 50 kostenlosen localhost-Tests diesen Monat aufgebraucht. Wähle einen Tarif oder nutze Send test im Dashboard. |
Fragen
Nicht von allein. Ein Formular macht nur einen HTTP-Request, und Mail zu senden braucht einen Server, der mit einem Mailserver spricht. Ein Formular-Backend ist dieser Server: Richte die action des Formulars darauf aus, und es schickt dir E-Mails, sodass deine eigene Site statisch bleiben kann.
Eine mailto:-action sendet gar nichts. Sie bittet den Computer des Besuchers, eine Mail-App mit einem Entwurf zu öffnen, was bei allen ohne eingerichtete Mail-App scheitert, und der Besucher muss trotzdem noch auf Senden klicken. Viele geben da auf, und du erfährst nie, dass sie es versucht haben.
Nein. Der Betreff ist immer „New submission:“ gefolgt vom Namen des Formulars, also benenne jedes Formular klar. Ein Feld _subject wird gespeichert und an Webhooks und die API weitergegeben, ändert aber die E-Mail nicht.
Bis zu 10 pro Formular: Workspace-Mitglieder, die du in den Einstellungen des Formulars anhakst, plus bis zu 5 externe Adressen, die per Link bestätigen. Jede Person bekommt ihre eigene Kopie.
Nein. Ikiform schickt E-Mails an dich und dein Team, nicht an den Absender, es gibt also keinen Autoresponder und keine Bestätigungs-E-Mail. Sende einen Webhook an dein E-Mail-Tool und verschicke die Bestätigung von dort.
Sie werden von der eigenen Domain von Ikiform gesendet, du brauchst also keine SPF- oder DKIM-Einträge für deine Domain. Die erste kann trotzdem im Spam oder in Werbung landen; füge notifications@ikiform.com zu deinen Kontakten hinzu oder leg einen Filter an, damit das nie passiert.
Nein, es gibt keinen kostenlosen Tarif. Testen ist kostenlos: Du kannst 50 Testeinsendungen pro Monat von localhost senden, bevor du zahlst. Für den Livebetrieb startet Pro bei $6/Monat für 500 Einsendungen, oder du zahlst einmalig 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.