From 39f233dbb4c2b7832bcb21d1622784fdee688985 Mon Sep 17 00:00:00 2001 From: Chandler Copeland Date: Thu, 19 Mar 2026 14:59:23 -0600 Subject: [PATCH] feat(02-02): install nodemailer and create contact mailer + server action - Add nodemailer@^7.0.7 and @types/nodemailer (v7 required by next-auth peer dep) - Create src/lib/contact-mailer.ts: Nodemailer SMTP transporter + sendContactEmail() - Create src/lib/contact-action.ts: Server Action with Zod validation, honeypot check - SMTP credentials read from CONTACT_EMAIL_USER/PASS/SMTP_HOST/PORT env vars - Add CONTACT_* placeholder vars to .env.local (gitignored, for local setup docs) Co-Authored-By: Claude Sonnet 4.6 --- teressa-copeland-homes/package-lock.json | 31 ++++++++++++ teressa-copeland-homes/package.json | 3 ++ .../src/lib/contact-action.ts | 48 +++++++++++++++++++ .../src/lib/contact-mailer.ts | 31 ++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 teressa-copeland-homes/src/lib/contact-action.ts create mode 100644 teressa-copeland-homes/src/lib/contact-mailer.ts diff --git a/teressa-copeland-homes/package-lock.json b/teressa-copeland-homes/package-lock.json index 5c367e5..3e898be 100644 --- a/teressa-copeland-homes/package-lock.json +++ b/teressa-copeland-homes/package-lock.json @@ -11,8 +11,10 @@ "@vercel/blob": "^2.3.1", "bcryptjs": "^3.0.3", "drizzle-orm": "^0.45.1", + "lucide-react": "^0.577.0", "next": "16.2.0", "next-auth": "5.0.0-beta.30", + "nodemailer": "^7.0.13", "postgres": "^3.4.8", "react": "19.2.4", "react-dom": "19.2.4", @@ -22,6 +24,7 @@ "@tailwindcss/postcss": "^4", "@types/bcryptjs": "^2.4.6", "@types/node": "^20", + "@types/nodemailer": "^7.0.11", "@types/react": "^19", "@types/react-dom": "^19", "dotenv": "^17.3.1", @@ -2521,6 +2524,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz", @@ -6138,6 +6151,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6387,6 +6409,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/oauth4webapi": { "version": "3.8.5", "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", diff --git a/teressa-copeland-homes/package.json b/teressa-copeland-homes/package.json index a8ab9d8..497adac 100644 --- a/teressa-copeland-homes/package.json +++ b/teressa-copeland-homes/package.json @@ -16,8 +16,10 @@ "@vercel/blob": "^2.3.1", "bcryptjs": "^3.0.3", "drizzle-orm": "^0.45.1", + "lucide-react": "^0.577.0", "next": "16.2.0", "next-auth": "5.0.0-beta.30", + "nodemailer": "^7.0.13", "postgres": "^3.4.8", "react": "19.2.4", "react-dom": "19.2.4", @@ -27,6 +29,7 @@ "@tailwindcss/postcss": "^4", "@types/bcryptjs": "^2.4.6", "@types/node": "^20", + "@types/nodemailer": "^7.0.11", "@types/react": "^19", "@types/react-dom": "^19", "dotenv": "^17.3.1", diff --git a/teressa-copeland-homes/src/lib/contact-action.ts b/teressa-copeland-homes/src/lib/contact-action.ts new file mode 100644 index 0000000..1dc8625 --- /dev/null +++ b/teressa-copeland-homes/src/lib/contact-action.ts @@ -0,0 +1,48 @@ +'use server' + +import { z } from 'zod' +import { sendContactEmail } from './contact-mailer' + +const ContactSchema = z.object({ + name: z.string().min(1, 'Name is required'), + email: z.string().email('Valid email address required'), + phone: z.string().min(7, 'Phone number is required'), + message: z.string().min(10, 'Message must be at least 10 characters'), + website: z.string(), // honeypot — must remain empty +}) + +export type ContactState = { + status: 'idle' | 'success' | 'error' + message?: string +} + +export async function submitContact( + _prev: ContactState, + formData: FormData, +): Promise { + const raw = { + name: formData.get('name'), + email: formData.get('email'), + phone: formData.get('phone'), + message: formData.get('message'), + website: formData.get('website') ?? '', + } + + const parsed = ContactSchema.safeParse(raw) + if (!parsed.success) { + return { status: 'error', message: 'Please fill in all required fields correctly.' } + } + + // Honeypot check — if filled, silently succeed (don't reveal rejection to bots) + if (parsed.data.website !== '') { + return { status: 'success' } + } + + try { + await sendContactEmail(parsed.data) + return { status: 'success' } + } catch (err) { + console.error('Contact form email error:', err) + return { status: 'error', message: 'Something went wrong. Please try again or call directly.' } + } +} diff --git a/teressa-copeland-homes/src/lib/contact-mailer.ts b/teressa-copeland-homes/src/lib/contact-mailer.ts new file mode 100644 index 0000000..4af5c72 --- /dev/null +++ b/teressa-copeland-homes/src/lib/contact-mailer.ts @@ -0,0 +1,31 @@ +import nodemailer from 'nodemailer' + +const transporter = nodemailer.createTransport({ + host: process.env.CONTACT_SMTP_HOST, + port: Number(process.env.CONTACT_SMTP_PORT ?? 587), + secure: false, // STARTTLS on port 587 + auth: { + user: process.env.CONTACT_EMAIL_USER, + pass: process.env.CONTACT_EMAIL_PASS, + }, +}) + +export async function sendContactEmail(data: { + name: string + email: string + phone: string + message: string +}) { + await transporter.sendMail({ + from: `"Teressa Copeland Homes Website" <${process.env.CONTACT_EMAIL_USER}>`, + replyTo: data.email, + to: process.env.CONTACT_EMAIL_USER, + subject: `New contact from ${data.name} — Teressa Copeland Homes`, + text: `Name: ${data.name}\nEmail: ${data.email}\nPhone: ${data.phone}\n\nMessage:\n${data.message}`, + html: `

Name: ${data.name}

+

Email: ${data.email}

+

Phone: ${data.phone}

+

Message:

+

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

`, + }) +}