feat(portfolio): rebuild w-make.com as a bilingual editorial studio
Give clients a real IA, SMTP contact to eldov@w-make.de, and live Batch evidence instead of a single-page placeholder. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
"use server";
|
||||
|
||||
import { headers } from "next/headers";
|
||||
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 async function submitInquiry(formData: FormData): Promise<InquiryActionResult> {
|
||||
const parsed = validateInquiry({
|
||||
name: formData.get("name"),
|
||||
email: formData.get("email"),
|
||||
company: formData.get("company"),
|
||||
message: formData.get("message"),
|
||||
locale: formData.get("locale"),
|
||||
website: formData.get("website"),
|
||||
});
|
||||
|
||||
if (!parsed.ok && parsed.code === "spam") return { ok: true };
|
||||
if (!parsed.ok) return { ok: false, code: "invalid", field: parsed.field };
|
||||
|
||||
const headerList = await headers();
|
||||
const forwarded = headerList.get("x-forwarded-for")?.split(",")[0]?.trim();
|
||||
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" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { createConnection } from "node:net";
|
||||
import { connect as tlsConnect } from "node:tls";
|
||||
import type { Inquiry } from "./schema";
|
||||
|
||||
type SmtpConfig = {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
pass: string;
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
function smtpConfig(): SmtpConfig | null {
|
||||
const to = process.env.CONTACT_TO;
|
||||
const host = process.env.SMTP_HOST;
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASS;
|
||||
if (!to || !host || !user || !pass) return null;
|
||||
return {
|
||||
host,
|
||||
port: Number(process.env.SMTP_PORT || 465),
|
||||
user,
|
||||
pass,
|
||||
from: process.env.CONTACT_FROM || user,
|
||||
to,
|
||||
};
|
||||
}
|
||||
|
||||
function encodeAuth(value: string): string {
|
||||
return Buffer.from(value, "utf8").toString("base64");
|
||||
}
|
||||
|
||||
async function smtpSend(config: SmtpConfig, inquiry: Inquiry): Promise<void> {
|
||||
const body = [
|
||||
`Name: ${inquiry.name}`,
|
||||
`E-Mail: ${inquiry.email}`,
|
||||
`Unternehmen: ${inquiry.company || "—"}`,
|
||||
`Sprache: ${inquiry.locale}`,
|
||||
"",
|
||||
inquiry.message,
|
||||
].join("\n");
|
||||
|
||||
const subject = `W-MAKE Anfrage · ${inquiry.name}`.replace(/[\r\n]/g, " ");
|
||||
const message = [
|
||||
`From: ${config.from}`,
|
||||
`To: ${config.to}`,
|
||||
`Reply-To: ${inquiry.email}`,
|
||||
`Subject: =?UTF-8?B?${Buffer.from(subject, "utf8").toString("base64")}?=`,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"Content-Transfer-Encoding: 8bit",
|
||||
"",
|
||||
body,
|
||||
"",
|
||||
].join("\r\n");
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket =
|
||||
config.port === 465
|
||||
? tlsConnect({ host: config.host, port: config.port, servername: config.host })
|
||||
: createConnection({ host: config.host, port: config.port });
|
||||
|
||||
let buffer = "";
|
||||
let step = 0;
|
||||
const commands = [
|
||||
`EHLO w-make.com`,
|
||||
`AUTH LOGIN`,
|
||||
encodeAuth(config.user),
|
||||
encodeAuth(config.pass),
|
||||
`MAIL FROM:<${config.user}>`,
|
||||
`RCPT TO:<${config.to}>`,
|
||||
`DATA`,
|
||||
];
|
||||
|
||||
const write = (line: string) => socket.write(`${line}\r\n`);
|
||||
const fail = (error: Error) => {
|
||||
socket.destroy();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
const onLine = (line: string) => {
|
||||
const code = Number(line.slice(0, 3));
|
||||
if (code >= 400) {
|
||||
fail(new Error(`SMTP ${line}`));
|
||||
return;
|
||||
}
|
||||
if (step < commands.length) {
|
||||
write(commands[step]);
|
||||
step += 1;
|
||||
return;
|
||||
}
|
||||
if (step === commands.length) {
|
||||
write(`${message}\r\n.`);
|
||||
step += 1;
|
||||
return;
|
||||
}
|
||||
write("QUIT");
|
||||
socket.end();
|
||||
resolve();
|
||||
};
|
||||
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
const parts = buffer.split("\r\n");
|
||||
buffer = parts.pop() || "";
|
||||
for (const line of parts) {
|
||||
if (line && (line[3] === " " || line.length === 3)) onLine(line);
|
||||
}
|
||||
});
|
||||
socket.on("error", fail);
|
||||
socket.setTimeout(20000, () => fail(new Error("SMTP timeout")));
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendInquiryMail(inquiry: Inquiry): Promise<boolean> {
|
||||
const config = smtpConfig();
|
||||
if (!config) return false;
|
||||
try {
|
||||
await smtpSend(config, inquiry);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
const WINDOW_MS = 60 * 60 * 1000;
|
||||
const MAX_PER_WINDOW = 3;
|
||||
const hits = new Map<string, number[]>();
|
||||
|
||||
export function allowInquiry(key: string): boolean {
|
||||
const now = Date.now();
|
||||
const recent = (hits.get(key) || []).filter((time) => now - time < WINDOW_MS);
|
||||
if (recent.length >= MAX_PER_WINDOW) {
|
||||
hits.set(key, recent);
|
||||
return false;
|
||||
}
|
||||
recent.push(now);
|
||||
hits.set(key, recent);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { validateInquiry } from "./schema.ts";
|
||||
|
||||
describe("validateInquiry", () => {
|
||||
const valid = {
|
||||
name: "Anna Müller",
|
||||
email: "anna@example.com",
|
||||
company: "Glaswerk",
|
||||
message: "Wir brauchen eine belastbare Abbildung unseres Satzprozesses.",
|
||||
locale: "de",
|
||||
website: "",
|
||||
};
|
||||
|
||||
it("accepts a complete inquiry", () => {
|
||||
const result = validateInquiry(valid);
|
||||
assert.equal(result.ok, true);
|
||||
if (result.ok) {
|
||||
assert.equal(result.data.email, "anna@example.com");
|
||||
assert.equal(result.data.company, "Glaswerk");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a filled honeypot", () => {
|
||||
const result = validateInquiry({ ...valid, website: "https://spam.test" });
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) assert.equal(result.code, "spam");
|
||||
});
|
||||
|
||||
it("rejects short messages and invalid email", () => {
|
||||
assert.equal(validateInquiry({ ...valid, message: "Hallo" }).ok, false);
|
||||
assert.equal(validateInquiry({ ...valid, email: "not-an-email" }).ok, false);
|
||||
});
|
||||
|
||||
it("allows an empty company field", () => {
|
||||
const result = validateInquiry({ ...valid, company: " " });
|
||||
assert.equal(result.ok, true);
|
||||
if (result.ok) assert.equal(result.data.company, undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
export type InquiryInput = {
|
||||
name: unknown;
|
||||
email: unknown;
|
||||
company?: unknown;
|
||||
message: unknown;
|
||||
locale: unknown;
|
||||
website?: unknown;
|
||||
};
|
||||
|
||||
export type Inquiry = {
|
||||
name: string;
|
||||
email: string;
|
||||
company?: string;
|
||||
message: string;
|
||||
locale: "de" | "en";
|
||||
};
|
||||
|
||||
export type ValidationResult =
|
||||
| { ok: true; data: Inquiry }
|
||||
| { ok: false; code: "spam" | "invalid"; field?: string };
|
||||
|
||||
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function validateInquiry(input: InquiryInput): ValidationResult {
|
||||
if (typeof input.website === "string" && input.website.trim() !== "") {
|
||||
return { ok: false, code: "spam" };
|
||||
}
|
||||
|
||||
const name = typeof input.name === "string" ? input.name.trim() : "";
|
||||
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 locale = input.locale === "en" || input.locale === "de" ? input.locale : null;
|
||||
|
||||
if (name.length < 2 || name.length > 80) return { ok: false, code: "invalid", field: "name" };
|
||||
if (!EMAIL.test(email) || email.length > 160) return { ok: false, code: "invalid", field: "email" };
|
||||
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" };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
locale,
|
||||
...(companyRaw ? { company: companyRaw } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Inquiry } from "./schema";
|
||||
|
||||
let db: DatabaseSync | null = null;
|
||||
|
||||
function getDb(): DatabaseSync {
|
||||
if (db) return db;
|
||||
const dbPath = process.env.INQUIRIES_DB_PATH || "./data/inquiries.sqlite";
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
db = new DatabaseSync(dbPath);
|
||||
db.exec("PRAGMA journal_mode = WAL;");
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS inquiries (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
company TEXT,
|
||||
message TEXT NOT NULL,
|
||||
locale TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)`);
|
||||
return db;
|
||||
}
|
||||
|
||||
export function saveInquiry(inquiry: Inquiry): string {
|
||||
const id = randomUUID();
|
||||
getDb()
|
||||
.prepare(
|
||||
"INSERT INTO inquiries (id, name, email, company, message, locale, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
inquiry.name,
|
||||
inquiry.email,
|
||||
inquiry.company ?? null,
|
||||
inquiry.message,
|
||||
inquiry.locale,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
return id;
|
||||
}
|
||||
Reference in New Issue
Block a user