From 9a1be02c6c241da80d6cdb52006deca8318e63a2 Mon Sep 17 00:00:00 2001 From: kayjaydee Date: Wed, 8 Apr 2026 18:34:38 +0200 Subject: [PATCH] feat(03-01): create ContactForm with Zod validation and nodemailer SMTP server route - ContactForm.vue: UForm + Zod schema (name/email/message) + useToast feedback - server/api/contact.post.ts: nodemailer SMTP with server-side validation + HTML escaping - SMTP credentials in private runtimeConfig (T-03-03) - HTML escaping prevents XSS in email body (T-03-02) --- app/components/ContactForm.vue | 66 ++++++++++++++++++++++++++++++++++ server/api/contact.post.ts | 48 +++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 app/components/ContactForm.vue create mode 100644 server/api/contact.post.ts diff --git a/app/components/ContactForm.vue b/app/components/ContactForm.vue new file mode 100644 index 0000000..e2e1a05 --- /dev/null +++ b/app/components/ContactForm.vue @@ -0,0 +1,66 @@ + + + diff --git a/server/api/contact.post.ts b/server/api/contact.post.ts new file mode 100644 index 0000000..1eec936 --- /dev/null +++ b/server/api/contact.post.ts @@ -0,0 +1,48 @@ +import nodemailer from 'nodemailer' + +export default defineEventHandler(async (event) => { + const body = await readBody(event) + const config = useRuntimeConfig(event) + + // Server-side validation (T-03-01) + const { name, email, message } = body + if (!name || typeof name !== 'string' || name.length < 2 || name.length > 100) { + throw createError({ statusCode: 400, message: 'Invalid name' }) + } + if (!email || typeof email !== 'string' || !email.includes('@') || email.length > 200) { + throw createError({ statusCode: 400, message: 'Invalid email' }) + } + if (!message || typeof message !== 'string' || message.length < 10 || message.length > 5000) { + throw createError({ statusCode: 400, message: 'Invalid message' }) + } + + const transporter = nodemailer.createTransport({ + host: config.smtpHost, + port: 465, + secure: true, + auth: { + user: config.smtpUser, + pass: config.smtpPass, + }, + }) + + // Escape HTML to prevent XSS in email body (T-03-02) + const escapedName = name.replace(/[&<>"']/g, (c: string) => { + const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } + return map[c] ?? c + }) + const escapedMessage = message.replace(/[&<>"']/g, (c: string) => { + const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } + return map[c] ?? c + }) + + await transporter.sendMail({ + from: `"Portfolio" <${config.smtpUser}>`, + to: config.smtpTo, + subject: `Contact portfolio - ${name}`, + text: `De: ${name} <${email}>\n\n${message}`, + html: `

De: ${escapedName} <${email}>

${escapedMessage.replace(/\n/g, '
')}

`, + }) + + return { success: true } +})