feat(site): tighten proof, contact, and inquiry delivery

Lead with Batch on the home page, drop the product changelog, add a
domain note, and stop reporting success when SMTP fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jan Wagner
2026-08-16 11:12:40 +02:00
co-authored by Cursor
parent aa55c29dbf
commit 0482b780f4
23 changed files with 465 additions and 84 deletions
+7 -10
View File
@@ -1,14 +1,13 @@
"use server";
import { headers } from "next/headers";
import { deliverInquiry, type InquiryActionResult } from "./deliver";
import { allowInquiry } from "./rate-limit";
import { sendInquiryMail } from "./mail";
import { validateInquiry } from "./schema";
import { saveInquiry } from "./store";
export type InquiryActionResult =
| { ok: true }
| { ok: false; code: "invalid" | "rate" | "error"; field?: string };
export type { InquiryActionResult };
export async function submitInquiry(formData: FormData): Promise<InquiryActionResult> {
const parsed = validateInquiry({
@@ -18,6 +17,7 @@ export async function submitInquiry(formData: FormData): Promise<InquiryActionRe
message: formData.get("message"),
locale: formData.get("locale"),
website: formData.get("website"),
topic: formData.get("topic"),
});
if (!parsed.ok && parsed.code === "spam") return { ok: true };
@@ -28,11 +28,8 @@ export async function submitInquiry(formData: FormData): Promise<InquiryActionRe
const ip = forwarded || headerList.get("x-real-ip") || "unknown";
if (!allowInquiry(ip)) return { ok: false, code: "rate" };
try {
saveInquiry(parsed.data);
await sendInquiryMail(parsed.data);
return { ok: true };
} catch {
return { ok: false, code: "error" };
}
return deliverInquiry(parsed.data, {
save: saveInquiry,
send: sendInquiryMail,
});
}
+53
View File
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { deliverInquiry } from "./deliver.ts";
import type { Inquiry } from "./schema.ts";
const inquiry: Inquiry = {
name: "Anna Müller",
email: "anna@example.com",
message: "Wir brauchen eine belastbare Abbildung unseres Satzprozesses.",
locale: "de",
topic: "process",
};
describe("deliverInquiry", () => {
it("saves and succeeds when mail is sent", async () => {
const saved: Inquiry[] = [];
const result = await deliverInquiry(inquiry, {
save: (item) => {
saved.push(item);
return "id-1";
},
send: async () => true,
});
assert.deepEqual(result, { ok: true });
assert.equal(saved.length, 1);
});
it("does not report success when mail fails after save", async () => {
let saved = false;
const result = await deliverInquiry(inquiry, {
save: () => {
saved = true;
return "id-2";
},
send: async () => false,
});
assert.equal(saved, true);
assert.deepEqual(result, { ok: false, code: "delivery" });
});
it("returns error when save throws", async () => {
const result = await deliverInquiry(inquiry, {
save: () => {
throw new Error("disk");
},
send: async () => true,
});
assert.deepEqual(result, { ok: false, code: "error" });
});
});
+23
View File
@@ -0,0 +1,23 @@
import type { Inquiry } from "./schema";
export type InquiryActionResult =
| { ok: true }
| { ok: false; code: "invalid" | "rate" | "error" | "delivery"; field?: string };
export async function deliverInquiry(
inquiry: Inquiry,
deps: {
save: (inquiry: Inquiry) => string;
send: (inquiry: Inquiry) => Promise<boolean>;
},
): Promise<InquiryActionResult> {
try {
deps.save(inquiry);
} catch {
return { ok: false, code: "error" };
}
const mailed = await deps.send(inquiry);
if (!mailed) return { ok: false, code: "delivery" };
return { ok: true };
}
+1
View File
@@ -36,6 +36,7 @@ async function smtpSend(config: SmtpConfig, inquiry: Inquiry): Promise<void> {
`Name: ${inquiry.name}`,
`E-Mail: ${inquiry.email}`,
`Unternehmen: ${inquiry.company || "—"}`,
`Thema: ${inquiry.topic || "—"}`,
`Sprache: ${inquiry.locale}`,
"",
inquiry.message,
+21
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { validateInquiry } from "./schema.ts";
import { offerIds } from "../../content/offer-ids.ts";
describe("validateInquiry", () => {
const valid = {
@@ -37,4 +38,24 @@ describe("validateInquiry", () => {
assert.equal(result.ok, true);
if (result.ok) assert.equal(result.data.company, undefined);
});
it("accepts every published offer id", () => {
for (const topic of offerIds) {
const result = validateInquiry({ ...valid, topic });
assert.equal(result.ok, true);
if (result.ok) assert.equal(result.data.topic, topic);
}
});
it("rejects an unknown topic", () => {
const result = validateInquiry({ ...valid, topic: "casino" });
assert.equal(result.ok, false);
if (!result.ok) assert.equal(result.field, "topic");
});
it("treats a blank topic as omitted", () => {
const result = validateInquiry({ ...valid, topic: " " });
assert.equal(result.ok, true);
if (result.ok) assert.equal(result.data.topic, undefined);
});
});
+13
View File
@@ -5,14 +5,23 @@ export type InquiryInput = {
message: unknown;
locale: unknown;
website?: unknown;
topic?: unknown;
};
const OFFER_TOPICS = ["process", "product", "collaboration"] as const;
export type InquiryTopic = (typeof OFFER_TOPICS)[number];
function isInquiryTopic(value: string): value is InquiryTopic {
return (OFFER_TOPICS as readonly string[]).includes(value);
}
export type Inquiry = {
name: string;
email: string;
company?: string;
message: string;
locale: "de" | "en";
topic?: InquiryTopic;
};
export type ValidationResult =
@@ -30,6 +39,7 @@ export function validateInquiry(input: InquiryInput): ValidationResult {
const email = typeof input.email === "string" ? input.email.trim() : "";
const message = typeof input.message === "string" ? input.message.trim() : "";
const companyRaw = typeof input.company === "string" ? input.company.trim() : "";
const topicRaw = typeof input.topic === "string" ? input.topic.trim() : "";
const locale = input.locale === "en" || input.locale === "de" ? input.locale : null;
if (name.length < 2 || name.length > 80) return { ok: false, code: "invalid", field: "name" };
@@ -37,6 +47,8 @@ export function validateInquiry(input: InquiryInput): ValidationResult {
if (message.length < 20 || message.length > 4000) return { ok: false, code: "invalid", field: "message" };
if (!locale) return { ok: false, code: "invalid", field: "locale" };
if (companyRaw.length > 120) return { ok: false, code: "invalid", field: "company" };
if (topicRaw && !isInquiryTopic(topicRaw)) return { ok: false, code: "invalid", field: "topic" };
const topic: InquiryTopic | undefined = isInquiryTopic(topicRaw) ? topicRaw : undefined;
return {
ok: true,
@@ -46,6 +58,7 @@ export function validateInquiry(input: InquiryInput): ValidationResult {
message,
locale,
...(companyRaw ? { company: companyRaw } : {}),
...(topic ? { topic } : {}),
},
};
}
+8 -1
View File
@@ -19,8 +19,14 @@ function getDb(): DatabaseSync {
company TEXT,
message TEXT NOT NULL,
locale TEXT NOT NULL,
topic TEXT,
created_at TEXT NOT NULL
)`);
try {
db.exec("ALTER TABLE inquiries ADD COLUMN topic TEXT");
} catch {
// Existing databases already have the column after the first migrate.
}
return db;
}
@@ -28,7 +34,7 @@ export function saveInquiry(inquiry: Inquiry): string {
const id = randomUUID();
getDb()
.prepare(
"INSERT INTO inquiries (id, name, email, company, message, locale, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO inquiries (id, name, email, company, message, locale, topic, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.run(
id,
@@ -37,6 +43,7 @@ export function saveInquiry(inquiry: Inquiry): string {
inquiry.company ?? null,
inquiry.message,
inquiry.locale,
inquiry.topic ?? null,
new Date().toISOString(),
);
return id;