From b9dbd59e7d78b8cd58c36160b05c25ec5f317a52 Mon Sep 17 00:00:00 2001 From: Z User Date: Tue, 5 May 2026 21:05:37 +0000 Subject: [PATCH] feat: add email notifications for contact form submissions - Add nodemailer dependency for SMTP email delivery - Create src/services/email.js with reusable email service - Update POST /api/contact to send email notification after saving - Email is sent fire-and-forget (doesn't block API response) - Emails include both plain-text and HTML versions - Reply-To header set to submitter's email for easy replies - Add SMTP environment variables to .env.example - Document email setup in README with provider-specific guides --- .env.example | 12 ++++ README.md | 55 ++++++++++++++++ package-lock.json | 10 +++ package.json | 1 + src/routes/contact.js | 14 ++++ src/services/email.js | 149 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 241 insertions(+) create mode 100644 src/services/email.js diff --git a/.env.example b/.env.example index 2a4e447..51f73ab 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,15 @@ STRIPE_WEBHOOK_SECRET= PAYPAL_CLIENT_ID= PAYPAL_CLIENT_SECRET= PAYPAL_WEBHOOK_ID= + +# SMTP Email Configuration (for contact form notifications) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your-email@gmail.com +SMTP_PASS=your-app-password +SMTP_SECURE=false +SMTP_FROM=Moxie + +# Destination email for contact form submissions +# Defaults to SMTP_USER if not set +CONTACT_EMAIL=your-email@gmail.com diff --git a/README.md b/README.md index 62b9216..ab7c050 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,61 @@ UPDATE users SET role = 'admin' WHERE email = 'admin@example.com'; 3. Add credentials to environment variables 4. Configure webhook in PayPal dashboard pointing to `https://moxiegen.client.guacamolebox.net/api/webhooks/paypal` +## Email Configuration (Contact Form) + +Contact form submissions are automatically emailed to a configured address using Nodemailer with SMTP. + +### Setup + +Add the following to your `.env` file: + +```env +# SMTP Email Configuration +SMTP_HOST=smtp.gmail.com # Your SMTP server +SMTP_PORT=587 # Usually 587 (TLS) or 465 (SSL) +SMTP_USER=your-email@gmail.com # Email address to send from +SMTP_PASS=your-app-password # Password or app-specific password +SMTP_SECURE=false # true for port 465, false for 587 +SMTP_FROM=Moxie # From display name & address + +# Where contact form emails are sent (defaults to SMTP_USER) +CONTACT_EMAIL=notifications@yourdomain.com +``` + +### Gmail Users + +If you use Gmail, you must generate an **App Password** because regular passwords no longer work with SMTP: + +1. Go to [Google Account Security](https://myaccount.google.com/security) +2. Enable **2-Step Verification** (if not already enabled) +3. Go to **App passwords** (search in security settings) +4. Create a new app password for "Mail" +5. Use that 16-character password as `SMTP_PASS` + +### Other Providers + +| Provider | SMTP_HOST | SMTP_PORT | Notes | +|----------|-----------|-----------|-------| +| Gmail | smtp.gmail.com | 587 | Requires App Password | +| Outlook / Office 365 | smtp.office365.com | 587 | | +| SendGrid | smtp.sendgrid.net | 587 | Use API key as password | +| Mailgun | smtp.mailgun.org | 587 | | +| AWS SES | email-smtp.[region].amazonaws.com | 587 | Use SMTP credentials | + +### How It Works + +When someone submits the contact form (`POST /api/contact`): + +1. The submission is **saved to the database** (always succeeds) +2. An email notification is **sent in the background** (fire-and-forget) +3. If email sending fails, the form still saves — a warning is logged but the user is not affected + +The email includes: +- Plain-text and HTML versions +- The submitter's name, email, and company (if provided) +- The full message +- A reply-to header so you can reply directly to the submitter + ## License ISC diff --git a/package-lock.json b/package-lock.json index b9f948e..a4b0b69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "express-oauth2-jwt-bearer": "^1.7.4", "helmet": "^8.1.0", "jose": "^6.2.2", + "nodemailer": "^8.0.7", "sql.js": "^1.14.1", "uuid": "^13.0.0" } @@ -603,6 +604,15 @@ "node": ">= 0.6" } }, + "node_modules/nodemailer": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", + "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/package.json b/package.json index 8ce9557..efc8723 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "express-oauth2-jwt-bearer": "^1.7.4", "helmet": "^8.1.0", "jose": "^6.2.2", + "nodemailer": "^8.0.7", "sql.js": "^1.14.1", "uuid": "^13.0.0" } diff --git a/src/routes/contact.js b/src/routes/contact.js index 0815543..738bcca 100644 --- a/src/routes/contact.js +++ b/src/routes/contact.js @@ -1,6 +1,7 @@ import express from 'express'; import { dbRun, dbAll, dbGet } from '../db/database.js'; import { generateId, ApiResponse, asyncHandler } from '../utils/helpers.js'; +import { sendContactEmail } from '../services/email.js'; const router = express.Router(); @@ -64,6 +65,19 @@ router.post('/', asyncHandler(async (req, res) => { console.log(`New contact submission: ${submissionId} from ${email}`); + // Send email notification (fire-and-forget — don't block the response) + sendContactEmail({ name: name.trim(), email: email.trim().toLowerCase(), company: company?.trim(), message: message.trim() }) + .then((result) => { + if (result.success) { + console.log(`[Contact] Email sent for submission ${submissionId}`); + } else { + console.warn(`[Contact] Email failed for submission ${submissionId}: ${result.error}`); + } + }) + .catch((err) => { + console.error(`[Contact] Email error for submission ${submissionId}:`, err.message); + }); + res.status(201).json(ApiResponse(true, { id: submissionId, message: 'Thank you for your message! We\'ll get back to you soon.' diff --git a/src/services/email.js b/src/services/email.js new file mode 100644 index 0000000..9ef0156 --- /dev/null +++ b/src/services/email.js @@ -0,0 +1,149 @@ +import nodemailer from 'nodemailer'; + +/** + * Create a Nodemailer transport using SMTP environment variables. + * + * Required env vars: + * SMTP_HOST - e.g. smtp.gmail.com + * SMTP_PORT - e.g. 587 + * SMTP_USER - email address used for sending + * SMTP_PASS - password or app-specific password + * + * Optional env vars: + * SMTP_FROM - "From" display name & address (defaults to SMTP_USER) + * SMTP_SECURE - true / false (defaults to false; use true for port 465) + * CONTACT_EMAIL - destination for contact-form notifications + * (defaults to SMTP_USER) + */ +const createTransport = () => { + const host = process.env.SMTP_HOST; + const port = parseInt(process.env.SMTP_PORT, 10); + const user = process.env.SMTP_USER; + const pass = process.env.SMTP_PASS; + + if (!host || !port || !user || !pass) { + throw new Error( + 'Email service is not configured. ' + + 'Please set SMTP_HOST, SMTP_PORT, SMTP_USER, and SMTP_PASS in your .env file.' + ); + } + + return nodemailer.createTransport({ + host, + port, + secure: process.env.SMTP_SECURE === 'true', // true for 465, false for 587/other + auth: { user, pass }, + }); +}; + +const getFromAddress = () => + process.env.SMTP_FROM || process.env.SMTP_USER; + +const getContactEmail = () => + process.env.CONTACT_EMAIL || process.env.SMTP_USER; + +/** + * Verify that the SMTP transport can connect. + * Call this at startup (optional but recommended). + * @returns {Promise} + */ +export const verifyTransport = async () => { + try { + const transporter = createTransport(); + await transporter.verify(); + console.log('[Email] SMTP transport verified successfully'); + return true; + } catch (err) { + console.warn('[Email] SMTP transport verification failed:', err.message); + return false; + } +}; + +/** + * Send the contact-form submission as an email notification. + * + * @param {Object} params + * @param {string} params.name - Submitter's name + * @param {string} params.email - Submitter's email + * @param {string} [params.company]- Submitter's company (optional) + * @param {string} params.message - Message body + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ +export const sendContactEmail = async ({ name, email, company, message }) => { + try { + const transporter = createTransport(); + const from = getFromAddress(); + const to = getContactEmail(); + + const companyLine = company ? `Company: ${company}\n` : ''; + + const info = await transporter.sendMail({ + from: `"Moxie Contact Form" <${from}>`, + to, + replyTo: email, // allow replying directly to the submitter + subject: `[Moxie] New Contact Form Submission from ${name}`, + text: [ + `You received a new contact form submission.`, + '', + `Name: ${name}`, + `Email: ${email}`, + companyLine, + `Message:`, + message, + '', + `---`, + `Submitted at: ${new Date().toISOString()}`, + ].join('\n'), + html: ` +
+

New Contact Form Submission

+ + + + + + + + + + ${company ? ` + + + + ` : ''} +
Name${escapeHtml(name)}
Email + ${escapeHtml(email)} +
Company${escapeHtml(company)}
+

Message

+

${escapeHtml(message)}

+
+

+ Submitted at: ${new Date().toISOString()} +

+
+ `, + }); + + console.log(`[Email] Contact notification sent to ${to} (messageId: ${info.messageId})`); + return { success: true, messageId: info.messageId }; + } catch (err) { + console.error('[Email] Failed to send contact notification:', err.message); + return { success: false, error: err.message }; + } +}; + +/** + * Minimal HTML entity escaping for user-submitted content. + * @param {string} str + * @returns {string} + */ +function escapeHtml(str) { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export default { verifyTransport, sendContactEmail };