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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user