fix(contact): send the contact form through the mail service's /v1/send/message
POST /api/contact posted { to, subject, body, replyTo } to ${mailApiUrl}/send,
which the mail service (libreshop/mail src/app.py) does not have, so every
message failed with a 500. It now posts { to_email, subject, message } to
POST /v1/send/message, still to paperwork@muellerprints.de, with the same
subject and text as before. The service sets no Reply-To; the sender's
address stays in the text. A line break in the subject is replaced by a
space, so it cannot start another mail header.
mailApiUrl was read from runtimeConfig without being declared there, so
NUXT_MAIL_API_URL could not set it and the fallback http://mail:2222 was
always used. It is declared now, with that default; mp's compose.yml sets
no NUXT_MAIL_API_URL.
contactMail and mailSendUrl (server/utils/contactMail.ts) are pure and tested.
Refs libretech/mp#71
This commit is contained in:
@@ -29,6 +29,7 @@ export default defineNuxtConfig({
|
|||||||
// Server-only (private) - never exposed to client
|
// Server-only (private) - never exposed to client
|
||||||
shopApiToken: "", // NUXT_SHOP_API_TOKEN
|
shopApiToken: "", // NUXT_SHOP_API_TOKEN
|
||||||
cmsInternalUrl: "http://cms:5555", // NUXT_CMS_INTERNAL_URL - internal docker network URL
|
cmsInternalUrl: "http://cms:5555", // NUXT_CMS_INTERNAL_URL - internal docker network URL
|
||||||
|
mailApiUrl: "http://mail:2222", // NUXT_MAIL_API_URL - mail service (libreshop/mail) on the internal docker network
|
||||||
siteUrl: "https://muellerprints-paperwork.com", // NUXT_SITE_URL - for sitemap
|
siteUrl: "https://muellerprints-paperwork.com", // NUXT_SITE_URL - for sitemap
|
||||||
|
|
||||||
// Public (client + server)
|
// Public (client + server)
|
||||||
|
|||||||
@@ -1,55 +1,18 @@
|
|||||||
|
import { contactMail, mailSendUrl } from "~/server/utils/contactMail";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await readBody(event);
|
const result = contactMail(await readBody(event));
|
||||||
|
if (result.ok === false) {
|
||||||
const { name, email, subject, message } = body;
|
throw createError({ statusCode: 400, message: result.message });
|
||||||
|
|
||||||
if (!name || !email || !message) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
message: "Name, E-Mail und Nachricht sind erforderlich",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate email format
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(email)) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
message: "Ungültige E-Mail-Adresse",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Send email via Mail service
|
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
const mailApiUrl = config.mailApiUrl || "http://mail:2222";
|
await $fetch(mailSendUrl(config.mailApiUrl || "http://mail:2222"), { method: "POST", body: result.mail });
|
||||||
|
|
||||||
await $fetch(`${mailApiUrl}/send`, {
|
|
||||||
method: "POST",
|
|
||||||
body: {
|
|
||||||
to: "paperwork@muellerprints.de",
|
|
||||||
subject: `Kontaktanfrage: ${subject || "Anfrage über Website"}`,
|
|
||||||
body: `
|
|
||||||
Name: ${name}
|
|
||||||
E-Mail: ${email}
|
|
||||||
Betreff: ${subject || "Kontaktanfrage über Website"}
|
|
||||||
|
|
||||||
Nachricht:
|
|
||||||
${message}
|
|
||||||
|
|
||||||
---
|
|
||||||
Diese Nachricht wurde über das Kontaktformular auf muellerprints.de gesendet.
|
|
||||||
`.trim(),
|
|
||||||
replyTo: email,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send contact email:", error);
|
console.error("Failed to send contact email:", error);
|
||||||
throw createError({
|
throw createError({ statusCode: 500, message: "E-Mail konnte nicht gesendet werden" });
|
||||||
statusCode: 500,
|
|
||||||
message: "E-Mail konnte nicht gesendet werden",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// The mail of the contact form (components/ContactForm.vue → POST /api/contact). The mail service (libreshop/mail src/app.py)
|
||||||
|
// sends it from POST /v1/send/message, whose JSON body is { to_email, subject, message, html? }. It sets no Reply-To, so the
|
||||||
|
// sender's address is in the text. Pure: no Nuxt, Nitro or h3 imports, tested in tests/unit/contactMail.test.ts.
|
||||||
|
|
||||||
|
export const CONTACT_RECIPIENT = "paperwork@muellerprints.de";
|
||||||
|
export const MAIL_SEND_PATH = "/v1/send/message";
|
||||||
|
|
||||||
|
/** The body of POST /v1/send/message. */
|
||||||
|
export type ContactMail = { to_email: string; subject: string; message: string };
|
||||||
|
|
||||||
|
/** The mail to send, or the German message of the 400 for the form. */
|
||||||
|
export type ContactMailResult = { ok: true; mail: ContactMail } | { ok: false; message: string };
|
||||||
|
|
||||||
|
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
const isFilled = (value: unknown): value is string => typeof value === "string" && value.trim() !== "";
|
||||||
|
|
||||||
|
// A line break in the subject would end its header and start another one.
|
||||||
|
const oneLine = (text: string): string => text.replace(/[\r\n]+/g, " ").trim();
|
||||||
|
|
||||||
|
/** The URL of the mail service's send endpoint, from its base URL (runtimeConfig.mailApiUrl). */
|
||||||
|
export const mailSendUrl = (mailApiUrl: string): string => `${mailApiUrl.replace(/\/+$/, "")}${MAIL_SEND_PATH}`;
|
||||||
|
|
||||||
|
/** Checks the form's body { name, email, subject?, message } and builds the mail to the shop. */
|
||||||
|
export const contactMail = (body: unknown): ContactMailResult => {
|
||||||
|
const fields: Record<string, unknown> = isObject(body) ? body : {};
|
||||||
|
const { name, email, subject, message } = fields;
|
||||||
|
if (!isFilled(name) || !isFilled(email) || !isFilled(message)) {
|
||||||
|
return { ok: false, message: "Name, E-Mail und Nachricht sind erforderlich" };
|
||||||
|
}
|
||||||
|
if (!EMAIL.test(email)) {
|
||||||
|
return { ok: false, message: "Ungültige E-Mail-Adresse" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const topic = typeof subject === "string" ? oneLine(subject) : "";
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
mail: {
|
||||||
|
to_email: CONTACT_RECIPIENT,
|
||||||
|
subject: `Kontaktanfrage: ${topic || "Anfrage über Website"}`,
|
||||||
|
message: [
|
||||||
|
`Name: ${name}`,
|
||||||
|
`E-Mail: ${email}`,
|
||||||
|
`Betreff: ${topic || "Kontaktanfrage über Website"}`,
|
||||||
|
"",
|
||||||
|
"Nachricht:",
|
||||||
|
message,
|
||||||
|
"",
|
||||||
|
"---",
|
||||||
|
"Diese Nachricht wurde über das Kontaktformular auf muellerprints.de gesendet."
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { CONTACT_RECIPIENT, contactMail, mailSendUrl } from "../../server/utils/contactMail.ts";
|
||||||
|
|
||||||
|
// components/ContactForm.vue posts these fields; it sends "Kontaktanfrage über Website" when the subject is left empty.
|
||||||
|
const form = {
|
||||||
|
name: "Erika Mustermann",
|
||||||
|
email: "erika@example.org",
|
||||||
|
subject: "Notizbuch mit Firmenlogo",
|
||||||
|
message: "Hallo,\nkönnt ihr 50 Stück liefern?"
|
||||||
|
};
|
||||||
|
|
||||||
|
const required = { ok: false, message: "Name, E-Mail und Nachricht sind erforderlich" };
|
||||||
|
|
||||||
|
test("sends to the mail service's /v1/send/message", () => {
|
||||||
|
assert.equal(mailSendUrl("http://mail:2222"), "http://mail:2222/v1/send/message");
|
||||||
|
assert.equal(mailSendUrl("http://mail:2222/"), "http://mail:2222/v1/send/message");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builds the body the mail service reads: to_email, subject and message", () => {
|
||||||
|
assert.deepEqual(contactMail(form), {
|
||||||
|
ok: true,
|
||||||
|
mail: {
|
||||||
|
to_email: "paperwork@muellerprints.de",
|
||||||
|
subject: "Kontaktanfrage: Notizbuch mit Firmenlogo",
|
||||||
|
message: [
|
||||||
|
"Name: Erika Mustermann",
|
||||||
|
"E-Mail: erika@example.org",
|
||||||
|
"Betreff: Notizbuch mit Firmenlogo",
|
||||||
|
"",
|
||||||
|
"Nachricht:",
|
||||||
|
"Hallo,\nkönnt ihr 50 Stück liefern?",
|
||||||
|
"",
|
||||||
|
"---",
|
||||||
|
"Diese Nachricht wurde über das Kontaktformular auf muellerprints.de gesendet."
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.equal(CONTACT_RECIPIENT, "paperwork@muellerprints.de");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fills in the subject when the form sends none", () => {
|
||||||
|
for (const subject of [undefined, "", " "]) {
|
||||||
|
const result = contactMail({ ...form, subject });
|
||||||
|
|
||||||
|
assert.equal(result.ok && result.mail.subject, "Kontaktanfrage: Anfrage über Website");
|
||||||
|
assert.equal(result.ok && result.mail.message.split("\n")[2], "Betreff: Kontaktanfrage über Website");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps a line break in the subject from starting another mail header", () => {
|
||||||
|
const result = contactMail({ ...form, subject: "Frage\r\nBcc: someone@example.org\nX-Spam: yes" });
|
||||||
|
|
||||||
|
assert.equal(result.ok && result.mail.subject, "Kontaktanfrage: Frage Bcc: someone@example.org X-Spam: yes");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires a name, an e-mail address and a message", () => {
|
||||||
|
for (const field of ["name", "email", "message"]) {
|
||||||
|
assert.deepEqual(contactMail({ ...form, [field]: undefined }), required, field);
|
||||||
|
assert.deepEqual(contactMail({ ...form, [field]: " " }), required, field);
|
||||||
|
assert.deepEqual(contactMail({ ...form, [field]: ["a"] }), required, field);
|
||||||
|
}
|
||||||
|
for (const body of [undefined, null, "name=Erika", [form]]) assert.deepEqual(contactMail(body), required);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an invalid e-mail address", () => {
|
||||||
|
for (const email of ["erika", "erika@example", "erika @example.org", "@example.org"]) {
|
||||||
|
assert.deepEqual(contactMail({ ...form, email }), { ok: false, message: "Ungültige E-Mail-Adresse" }, email);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user