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 <noreply@anthropic.com>
This commit is contained in:
31
teressa-copeland-homes/package-lock.json
generated
31
teressa-copeland-homes/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
48
teressa-copeland-homes/src/lib/contact-action.ts
Normal file
48
teressa-copeland-homes/src/lib/contact-action.ts
Normal file
@@ -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<ContactState> {
|
||||
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.' }
|
||||
}
|
||||
}
|
||||
31
teressa-copeland-homes/src/lib/contact-mailer.ts
Normal file
31
teressa-copeland-homes/src/lib/contact-mailer.ts
Normal file
@@ -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: `<p><strong>Name:</strong> ${data.name}</p>
|
||||
<p><strong>Email:</strong> <a href="mailto:${data.email}">${data.email}</a></p>
|
||||
<p><strong>Phone:</strong> ${data.phone}</p>
|
||||
<p><strong>Message:</strong></p>
|
||||
<p>${data.message.replace(/\n/g, '<br/>')}</p>`,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user