From 6c41faf36aa2ef3e1d773c144a6526b29050c9be Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Fri, 9 Oct 2026 02:32:44 +0200 Subject: [PATCH] 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 https://git.librete.ch/libretech/mp/issues/71 --- nuxt.config.ts | 1 + server/api/contact.post.ts | 51 ++++--------------------- server/utils/contactMail.ts | 56 +++++++++++++++++++++++++++ tests/unit/contactMail.test.ts | 70 ++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 44 deletions(-) create mode 100644 server/utils/contactMail.ts create mode 100644 tests/unit/contactMail.test.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index 67c6312..9e04544 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -29,6 +29,7 @@ export default defineNuxtConfig({ // Server-only (private) - never exposed to client shopApiToken: "", // NUXT_SHOP_API_TOKEN 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 // Public (client + server) diff --git a/server/api/contact.post.ts b/server/api/contact.post.ts index e5c5d44..cb20740 100644 --- a/server/api/contact.post.ts +++ b/server/api/contact.post.ts @@ -1,55 +1,18 @@ +import { contactMail, mailSendUrl } from "~/server/utils/contactMail"; + export default defineEventHandler(async (event) => { - const body = await readBody(event); - - const { name, email, subject, message } = body; - - 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", - }); + const result = contactMail(await readBody(event)); + if (result.ok === false) { + throw createError({ statusCode: 400, message: result.message }); } try { - // Send email via Mail service const config = useRuntimeConfig(); - const mailApiUrl = config.mailApiUrl || "http://mail:2222"; - - 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, - }, - }); + await $fetch(mailSendUrl(config.mailApiUrl || "http://mail:2222"), { method: "POST", body: result.mail }); return { success: true }; } catch (error) { console.error("Failed to send contact email:", error); - throw createError({ - statusCode: 500, - message: "E-Mail konnte nicht gesendet werden", - }); + throw createError({ statusCode: 500, message: "E-Mail konnte nicht gesendet werden" }); } }); diff --git a/server/utils/contactMail.ts b/server/utils/contactMail.ts new file mode 100644 index 0000000..3f7e70e --- /dev/null +++ b/server/utils/contactMail.ts @@ -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 => 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 = 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") + } + }; +}; diff --git a/tests/unit/contactMail.test.ts b/tests/unit/contactMail.test.ts new file mode 100644 index 0000000..ab12df7 --- /dev/null +++ b/tests/unit/contactMail.test.ts @@ -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); + } +});